Repository: Canop/bacon (language: rust)

Issue:
Support for non-interactive terminals and containers
Many of us develop in non-interactive terminals. Particularly those of us who work with embedded programming, or run dev stacks in containers, do not have the luxury of a text-based interactive UI. However, we'd still love a job runner that responds to filesystem changes.

It's important that bacon can be run in a mode where its only output is to stdout/stderr, it expects no input, and all it does is just runs the default job. Just a boring, colorless task runner to be run from a container.


Discussion / hints:
What output do you expect in such headless mode ? Just the raw output of the command called by bacon ? Unsorted and untrimmed ?
> What output do you expect in such headless mode ? Just the raw output of the command called by bacon ? Unsorted and untrimmed ?

That would be a fantastic start and make Bacon immediately usable for many.

Later, it could also mix in its own logs for starting up or detecting a change. Just plain log strings prefixed to denote they're coming from Bacon itself and not the task that's being run.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 0f320d8..223849d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -133,6 +133,7 @@ dependencies = [
  "clap-help",
  "cli-log",
  "crokey",
+ "ctrlc",
  "directories-next",
  "gix",
  "glob",
@@ -450,6 +451,16 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "ctrlc"
+version = "3.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90eeab0aa92f3f9b4e87f258c72b139c207d251f9cbc1080a0086b86a8870dd3"
+dependencies = [
+ "nix",
+ "windows-sys 0.59.0",
+]
+
 [[package]]
 name = "directories-next"
 version = "2.0.0"
diff --git a/Cargo.toml b/Cargo.toml
index 3550722..7a07ebc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,7 @@ rust-version = "1.76"
         
 [dependencies]
 anyhow = "1.0"
+ctrlc = "3.4"
 cargo_metadata = "0.19"
 clap = { version = "4.5", features = ["derive", "cargo"] }
 clap-help = "1.3"
@@ -40,7 +41,7 @@ codegen-units = 1
 
 [patch.crates-io]
 # clap-help = { path = "../clap-help" }
-# termimad = { path = "../termimad" }
+# termimad = { path = "../termimad" }
 # crokey = { path = "../crokey" }
 # coolor = { path = "../coolor" }
 # iq = { path = "../iq" }
diff --git a/src/app.rs b/src/app.rs
index 0312e61..6b16cf1 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2,7 +2,10 @@ use {
     crate::*,
     anyhow::Result,
     crokey::*,
-    std::time::Duration,
+    std::{
+        io::Write,
+        time::Duration,
+    },
     termimad::{
         EventSource,
         EventSourceOptions,
@@ -39,11 +42,23 @@ pub fn run(
     mut settings: Settings,
     args: &Args,
     location: Context,
+    headless: bool,
 ) -> Result<()> {
-    let event_source = EventSource::with_options(EventSourceOptions {
-        combine_keys: false,
-        ..Default::default()
-    })?;
+    let event_source = if headless {
+        // in headless mode, in some contexts, ctrl-c might not be enough to kill
+        // bacon so we add this handler
+        ctrlc::set_handler(move || {
+            eprintln!("bye");
+            std::process::exit(0);
+        })
+        .expect("Error setting Ctrl-C handler");
+        None
+    } else {
+        Some(EventSource::with_options(EventSourceOptions {
+            combine_keys: false,
+            ..Default::default()
+        })?)
+    };
     let mut job_stack = JobStack::default();
     let mut next_job = JobRef::Initial;
     let mut message = None;
@@ -55,7 +70,8 @@ pub fn run(
             }
         };
         let mission = location.mission(concrete_job_ref, job, &settings)?;
-        let do_after = app::run_mission(w, mission, &event_source, message.take())?;
+        let do_after =
+            app::run_mission(w, mission, event_source.as_ref(), message.take(), headless)?;
         match do_after {
             DoAfterMission::NextJob(job_ref) => {
                 next_job = job_ref;
@@ -81,8 +97,9 @@ pub fn run(
 fn run_mission(
     w: &mut W,
     mission: Mission,
-    event_source: &EventSource,
+    event_source: Option<&EventSource>,
     message: Option<Message>,
+    headless: bool,
 ) -> Result<DoAfterMission> {
     let keybindings = mission.settings.keybindings.clone();
 
@@ -100,12 +117,14 @@ fn run_mission(
         .on_change_strategy
         .or(mission.settings.on_change_strategy)
         .unwrap_or(OnChangeStrategy::WaitThenRestart);
-    let mut state = AppState::new(mission)?;
+    let mut state = AppState::new(mission, headless)?;
     if let Some(message) = message {
         state.messages.push(message);
     }
     state.computation_starts();
-    state.draw(w)?;
+    if !headless {
+        state.draw(w)?;
+    }
     let mut task_executor = executor.start(state.new_task())?; // first computation
 
     // A very low frequency tick generator, to ensure "config loaded" message doesn't stick
@@ -113,9 +132,17 @@ fn run_mission(
     let mut ticker = Ticker::new();
     ticker.tick_infinitely((), Duration::from_secs(5));
 
-    // loop on events
-    let user_events = event_source.receiver();
+    let _dummy_sender;
+    let user_events = if let Some(event_source) = event_source {
+        event_source.receiver()
+    } else {
+        let (sender, receiver) = termimad::crossbeam::channel::unbounded();
+        _dummy_sender = sender;
+        receiver
+    };
     let mut do_after_mission = DoAfterMission::Quit;
+
+    // loop on events
     #[allow(unused_mut)]
     loop {
         let mut action: Option<&Action> = None;
@@ -144,12 +171,26 @@ fn run_mission(
                 if let Ok(info) = info {
                     match info {
                         CommandExecInfo::Line(line) => {
+                            if headless {
+                                match line.origin {
+                                    CommandStream::StdOut => print!("{}", line.content),
+                                    CommandStream::StdErr => eprint!("{}", line.content),
+                                }
+                            }
+                            let line = line.into();
                             state.add_line(line);
                         }
                         CommandExecInfo::End { status } => {
                             // computation finished
                             info!("execution finished with status: {:?}", status);
                             state.finish_task(status)?;
+                            if headless {
+                                for badge in state.job_badges() {
+                                    badge.draw(w)?;
+                                }
+                                write!(w, "\n")?;
+                                w.flush()?;
+                            }
                             action = state.action();
                         }
                         CommandExecInfo::Error(e) => {
@@ -184,7 +225,9 @@ fn run_mission(
                     }
                     _ => {}
                 }
-                event_source.unblock(false);
+                if let Some(event_source) = event_source {
+                    event_source.unblock(false);
+                }
             }
         }
         if let Some(action) = action.take() {
@@ -297,7 +340,9 @@ fn run_mission(
                 }
             }
         }
-        state.draw(w)?;
+        if !headless {
+            state.draw(w)?;
+        }
     }
     task_executor.die();
     Ok(do_after_mission)
diff --git a/src/cli.rs b/src/cli.rs
index c2320f3..9977367 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -33,6 +33,7 @@ pub fn run() -> anyhow::Result<()> {
     let mut args: Args = Args::parse();
     args.fix()?;
     info!("args: {:#?}", &args);
+    let headless = args.headless;
 
     if args.help {
         args.print_help();
@@ -81,16 +82,20 @@ pub fn run() -> anyhow::Result<()> {
     }
 
     let mut w = writer();
-    w.queue(EnterAlternateScreen)?;
-    w.queue(cursor::Hide)?;
-    #[cfg(windows)]
-    w.queue(EnableMouseCapture)?;
-    w.flush()?;
-    let result = app::run(&mut w, settings, &args, context);
-    #[cfg(windows)]
-    w.queue(DisableMouseCapture)?;
-    w.queue(cursor::Show)?;
-    w.queue(LeaveAlternateScreen)?;
+    if !headless {
+        w.queue(EnterAlternateScreen)?;
+        w.queue(cursor::Hide)?;
+        #[cfg(windows)]
+        w.queue(EnableMouseCapture)?;
+        w.flush()?;
+    }
+    let result = app::run(&mut w, settings, &args, context, headless);
+    if !headless {
+        #[cfg(windows)]
+        w.queue(DisableMouseCapture)?;
+        w.queue(cursor::Show)?;
+        w.queue(LeaveAlternateScreen)?;
+    }
     w.flush()?;
     result
 }
diff --git a/src/conf/args.rs b/src/conf/args.rs
index f907bc2..b18256c 100644
--- a/src/conf/args.rs
+++ b/src/conf/args.rs
@@ -44,6 +44,10 @@ pub struct Args {
     #[clap(long)]
     pub prefs: bool,
 
+    /// Run without user interface: just run the default job on change
+    #[clap(long)]
+    pub headless: bool,
+
     /// Start in summary mode
     #[clap(short = 's', long)]
     pub summary: bool,
diff --git a/src/exec/executor.rs b/src/exec/executor.rs
index 4662483..0ecad75 100644
--- a/src/exec/executor.rs
+++ b/src/exec/executor.rs
@@ -147,8 +147,8 @@ impl MissionExecutor {
                                 break;
                             }
                             Ok(_) => {
-                                let response = CommandExecInfo::Line(CommandOutputLine {
-                                    content: TLine::from_tty(&line),
+                                let response = CommandExecInfo::Line(RawCommandOutputLine {
+                                    content: line.clone(),
                                     origin: CommandStream::StdOut,
                                 });
                                 if sender.send(response).is_err() {
@@ -180,8 +180,8 @@ impl MissionExecutor {
                             break;
                         }
                         Ok(_) => {
-                            let response = CommandExecInfo::Line(CommandOutputLine {
-                                content: TLine::from_tty(&line),
+                            let response = CommandExecInfo::Line(RawCommandOutputLine {
+                                content: line.clone(),
                                 origin: CommandStream::StdErr,
                             });
                             if err_line_sender.send(response).is_err() {
diff --git a/src/result/command_output.rs b/src/result/command_output.rs
index f063bf4..76fd894 100644
--- a/src/result/command_output.rs
+++ b/src/result/command_output.rs
@@ -13,6 +13,13 @@ pub enum CommandStream {
     StdErr,
 }
 
+/// a line coming either from stdout or from stderr, before TTY parsing
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct RawCommandOutputLine {
+    pub content: String,
+    pub origin: CommandStream,
+}
+
 /// a line coming either from stdout or from stderr
 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
 pub struct CommandOutputLine {
@@ -39,7 +46,7 @@ pub enum CommandExecInfo {
     Error(anyhow::Error),
 
     /// Here's a line of output (coming from stderr or stdout)
-    Line(CommandOutputLine),
+    Line(RawCommandOutputLine),
 }
 
 impl CommandOutput {
@@ -56,3 +63,12 @@ impl CommandOutput {
         self.lines.len()
     }
 }
+
+impl From<RawCommandOutputLine> for CommandOutputLine {
+    fn from(raw: RawCommandOutputLine) -> Self {
+        CommandOutputLine {
+            content: TLine::from_tty(&raw.content),
+            origin: raw.origin,
+        }
+    }
+}
diff --git a/src/state.rs b/src/state.rs
index 359c063..eb940e0 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -86,14 +86,21 @@ pub struct AppState<'s> {
 }
 
 impl<'s> AppState<'s> {
-    pub fn new(mission: Mission<'s>) -> Result<Self> {
+    pub fn new(
+        mission: Mission<'s>,
+        headless: bool,
+    ) -> Result<Self> {
         let report_maker = ReportMaker::new(&mission);
         let mut status_skin = MadSkin::default();
         status_skin
             .paragraph
             .set_fgbg(AnsiValue(252), AnsiValue(239));
         status_skin.italic = CompoundStyle::new(Some(AnsiValue(204)), None, Attribute::Bold.into());
-        let (width, height) = termimad::terminal_size();
+        let (width, height) = if headless {
+            (50, 50)
+        } else {
+            termimad::terminal_size()
+        };
         let help_line = mission
             .settings
             .help_line
@@ -576,40 +583,47 @@ impl<'s> AppState<'s> {
         }
         Ok(())
     }
-    /// draw the line of colored badges, usually on top
-    pub fn draw_badges(
-        &mut self,
-        w: &mut W,
-        y: u16,
-    ) -> Result<usize> {
-        goto_line(w, y)?;
-        let mut t_line = TLine::default();
-        // white over grey
+
+    pub fn job_badges(&self) -> Vec<TString> {
+        let mut badges = Vec::new();
         let project_name = &self.mission.location_name;
-        t_line.add_badge(TString::badge(project_name, 255, 240));
-        // black over pink
+        badges.push(TString::badge(project_name, 255, 240));
         let job_label = self.mission.concrete_job_ref.badge_label();
-        t_line.add_badge(TString::badge(&job_label, 235, 204));
+        badges.push(TString::badge(&job_label, 235, 204));
         if let CommandResult::Report(report) = &self.cmd_result {
             let stats = &report.stats;
             if stats.errors > 0 {
-                t_line.add_badge(TString::num_badge(stats.errors, "error", 235, 9));
+                badges.push(TString::num_badge(stats.errors, "error", 235, 9));
             }
             if stats.test_fails > 0 {
-                t_line.add_badge(TString::num_badge(stats.test_fails, "fail", 235, 208));
+                badges.push(TString::num_badge(stats.test_fails, "fail", 235, 208));
             } else if stats.passed_tests > 0 {
-                t_line.add_badge(TString::badge("pass!", 254, 2));
+                badges.push(TString::badge("pass!", 254, 2));
             }
             if stats.warnings > 0 {
-                t_line.add_badge(TString::num_badge(stats.warnings, "warning", 235, 11));
+                badges.push(TString::num_badge(stats.warnings, "warning", 235, 11));
             }
         } else if let CommandResult::Failure(failure) = &self.cmd_result {
-            t_line.add_badge(TString::badge(
+            badges.push(TString::badge(
                 &format!("Command error code: {}", failure.error_code),
                 235,
                 9,
             ));
         }
+        badges
+    }
+
+    /// draw the line of colored badges, usually on top
+    pub fn draw_badges(
+        &mut self,
+        w: &mut W,
+        y: u16,
+    ) -> Result<usize> {
+        goto_line(w, y)?;
+        let mut t_line = TLine::default();
+        for badge in self.job_badges() {
+            t_line.add_badge(badge);
+        }
         if self.show_changes_count {
             t_line.add_badge(TString::num_badge(
                 self.changes_since_last_job_start,


Test patch: