Repository: Canop/bacon (language: rust)

Issue:
Automatically re-configure/re-run on updates to bacon.toml
I've been testing various custom jobs by adding them to and editing "bacon.toml".

Currently my flow is:
1. Edit "bacon.toml".
2. Kill current `bacon`.
2. Re-run `bacon <custom_job>`.

I think it would be nice if `bacon` watched "bacon.toml" for updates and restarted itself to load the new configuration and try any changes.

If this sounds like a useful feature I wouldn't mind taking a try at implementing it.

Thanks for a great tool!


Discussion / hints:
I didn't think this would be needed but I have no objection.

If you don't find an obvious clean way to code it, I'll do it myself.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d5130f..575b51c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+### next
+#### Major feature: hot reload of config files
+When a configuration file is modified, bacon automatically reloads its config. So you don't need to quit/relaunch when you add a new job, add a key-binding, change the allowed lints of clippy, etc. - Fix #29
+
 <a name="v3.0.0"></a>
 ### v3.0.0 - 2024/10/09
 #### Major feature: nextest support
diff --git a/Cargo.lock b/Cargo.lock
index 7df7273..3a0f498 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -126,7 +126,7 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
 
 [[package]]
 name = "bacon"
-version = "3.0.0"
+version = "3.0.1-dev"
 dependencies = [
  "anyhow",
  "cargo_metadata",
diff --git a/Cargo.toml b/Cargo.toml
index 604483d..5d06e05 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "bacon"
-version = "3.0.0"
+version = "3.0.1-dev"
 authors = ["dystroy <denys.seguret@gmail.com>"]
 repository = "https://github.com/Canop/bacon"
 description = "background rust compiler"
diff --git a/src/app.rs b/src/app.rs
index 043b813..d2be766 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2,19 +2,10 @@ use {
     crate::*,
     anyhow::Result,
     crokey::*,
-    crossbeam::channel::{
-        bounded,
-        select,
-    },
-    notify::event::{
-        AccessKind,
-        AccessMode,
-        DataChange,
-        EventKind,
-        ModifyKind,
-    },
+    crossbeam::channel::select,
     termimad::{
         EventSource,
+        EventSourceOptions,
         crossterm::event::Event,
     },
 };
@@ -28,82 +19,106 @@ use {
     },
 };
 
-/// Run the mission and return the reference to the next job to run, if any
+enum DoAfterMission {
+    NextJob(JobRef),
+    ReloadConfig,
+    Quit,
+}
+
+impl From<JobRef> for DoAfterMission {
+    fn from(job_ref: JobRef) -> Self {
+        Self::NextJob(job_ref)
+    }
+}
+
+/// Run the application until the user quits
 pub fn run(
+    w: &mut W,
+    mut settings: Settings,
+    args: &Args,
+    location: MissionLocation,
+) -> Result<()> {
+    let event_source = 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;
+    loop {
+        let (concrete_job_ref, job) = match job_stack.pick_job(&next_job, &settings)? {
+            Some(t) => t,
+            None => {
+                break;
+            }
+        };
+        let mission = Mission::new(&location, concrete_job_ref, job, &settings)?;
+        let do_after = app::run_mission(w, mission, &event_source, message.take())?;
+        match do_after {
+            DoAfterMission::NextJob(job_ref) => {
+                next_job = job_ref;
+            }
+            DoAfterMission::ReloadConfig => match Settings::read(args, &location) {
+                Ok(new_settings) => {
+                    settings = new_settings;
+                    message = Some(Message::short("Config reloaded"));
+                }
+                Err(e) => {
+                    message = Some(Message::short(format!("Invalid config: {e}")));
+                }
+            },
+            DoAfterMission::Quit => {
+                break;
+            }
+        }
+    }
+    Ok(())
+}
+
+/// Run the mission and return what to do afterwards
+fn run_mission(
     w: &mut W,
     mission: Mission,
     event_source: &EventSource,
-) -> Result<Option<JobRef>> {
+    message: Option<Message>,
+) -> Result<DoAfterMission> {
     let keybindings = mission.settings.keybindings.clone();
-    let mut ignorer = time!(Info, mission.ignorer());
-    let (watch_sender, watch_receiver) = bounded(0);
+
+    // build the watcher detecting and transmitting mission file changes
+    let ignorer = time!(Info, mission.ignorer());
+    let mission_watcher = Watcher::new(
+        &mission.files_to_watch,
+        &mission.directories_to_watch,
+        ignorer,
+    )?;
+
+    // create the watcher for config file changes
+    let config_watcher = Watcher::new(&mission.settings.config_files, &[], None)?;
+
+    // create the executor, mission, and state
+    let mut executor = MissionExecutor::new(&mission)?;
+    let analyzer = mission.analyzer();
     let on_change_strategy = mission
         .job
         .on_change_strategy
         .or(mission.settings.on_change_strategy)
         .unwrap_or(OnChangeStrategy::WaitThenRestart);
-    let mut watcher =
-        notify::recommended_watcher(move |res: notify::Result<notify::Event>| match res {
-            Ok(we) => {
-                match we.kind {
-                    EventKind::Modify(ModifyKind::Metadata(_)) => {
-                        debug!("ignoring metadata change");
-                        return; // useless event
-                    }
-                    EventKind::Modify(ModifyKind::Data(DataChange::Any)) => {
-                        debug!("ignoring 'any' data change");
-                        return; // probably useless event with no real change
-                    }
-                    EventKind::Access(AccessKind::Close(AccessMode::Write)) => {
-                        debug!("close write event: {we:?}");
-                    }
-                    EventKind::Access(_) => {
-                        debug!("ignoring access event: {we:?}");
-                        return; // probably useless event
-                    }
-                    _ => {
-                        debug!("notify event: {we:?}");
-                    }
-                }
-                if let Some(ignorer) = ignorer.as_mut() {
-                    match time!(Info, ignorer.excludes_all(&we.paths)) {
-                        Ok(true) => {
-                            debug!("all excluded");
-                            return;
-                        }
-                        Ok(false) => {
-                            debug!("at least one is included");
-                        }
-                        Err(e) => {
-                            warn!("exclusion check failed: {e}");
-                        }
-                    }
-                }
-                if let Err(e) = watch_sender.send(()) {
-                    debug!("error when notifying on inotify event: {}", e);
-                }
-            }
-            Err(e) => warn!("watch error: {:?}", e),
-        })?;
-
-    mission.add_watchs(&mut watcher)?;
-
-    let mut executor = MissionExecutor::new(&mission)?;
-
-    let analyzer = mission.analyzer();
     let mut state = AppState::new(mission)?;
+    if let Some(message) = message {
+        state.messages.push(message);
+    }
     state.computation_starts();
     state.draw(w)?;
-
     let mut task_executor = executor.start(state.new_task())?; // first computation
 
+    // loop on events
     let user_events = event_source.receiver();
-    let mut next_job: Option<JobRef> = None;
+    let mut do_after_mission = DoAfterMission::Quit;
     #[allow(unused_mut)]
     loop {
         let mut action: Option<&Action> = None;
         select! {
-            recv(watch_receiver) -> _ => {
+            recv(mission_watcher.receiver) -> _ => {
                 debug!("watch event received");
                 if task_executor.is_in_grace_period() {
                     debug!("ignoring notify event in grace period");
@@ -116,6 +131,10 @@ pub fn run(
                     }
                 }
             }
+            recv(config_watcher.receiver) -> _ => {
+                info!("config watch event received");
+                action = Some(&Action::Internal(Internal::ReloadConfig));
+            }
             recv(executor.line_receiver) -> info => {
                 if let Ok(info) = info {
                     match info {
@@ -181,7 +200,7 @@ pub fn run(
                 Action::Internal(internal) => match internal {
                     Internal::Back => {
                         if !state.close_help() {
-                            next_job = Some(JobRef::Previous);
+                            do_after_mission = DoAfterMission::NextJob(JobRef::Previous);
                             break;
                         }
                     }
@@ -196,6 +215,10 @@ pub fn run(
                         task_executor.die();
                         task_executor = state.start_computation(&mut executor)?;
                     }
+                    Internal::ReloadConfig => {
+                        do_after_mission = DoAfterMission::ReloadConfig;
+                        break;
+                    }
                     Internal::ReRun => {
                         task_executor.die();
                         task_executor = state.start_computation(&mut executor)?;
@@ -203,7 +226,7 @@ pub fn run(
                     Internal::ScopeToFailures => {
                         if let Some(scope) = state.failures_scope() {
                             info!("scoping to failures: {scope:#?}");
-                            next_job = Some(JobRef::Scope(scope));
+                            do_after_mission = JobRef::from(scope).into();
                             break;
                         } else {
                             warn!("no available failures scope");
@@ -253,7 +276,7 @@ pub fn run(
                     },
                 },
                 Action::Job(job_ref) => {
-                    next_job = Some((*job_ref).clone());
+                    do_after_mission = job_ref.clone().into();
                     break;
                 }
             }
@@ -261,5 +284,5 @@ pub fn run(
         state.draw(w)?;
     }
     task_executor.die();
-    Ok(next_job)
+    Ok(do_after_mission)
 }
diff --git a/src/cli.rs b/src/cli.rs
index 483ad9e..77bd5a6 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,21 +1,17 @@
 use {
     crate::*,
+    anyhow::anyhow,
     clap::Parser,
-    directories_next::ProjectDirs,
     std::{
         fs,
         io::Write,
     },
-    termimad::{
-        EventSource,
-        EventSourceOptions,
-        crossterm::{
-            QueueableCommand,
-            cursor,
-            terminal::{
-                EnterAlternateScreen,
-                LeaveAlternateScreen,
-            },
+    termimad::crossterm::{
+        QueueableCommand,
+        cursor,
+        terminal::{
+            EnterAlternateScreen,
+            LeaveAlternateScreen,
         },
     },
 };
@@ -48,50 +44,25 @@ pub fn run() -> anyhow::Result<()> {
         return Ok(());
     }
 
-    let mut settings = Settings::default();
-
-    let default_package_config = Config::default_package_config();
-    settings.apply_config(&default_package_config);
-
-    if let Some(project_dir) = ProjectDirs::from("org", "dystroy", "bacon") {
-        let prefs_path = project_dir.config_dir().join("prefs.toml");
-        if args.prefs {
-            if !prefs_path.exists() {
-                fs::create_dir_all(prefs_path.parent().unwrap())?;
-                fs::write(&prefs_path, DEFAULT_PREFS.trim_start())?;
-                // written to stderr to allow initialization with commands like
-                //  $EDITOR "$(bacon --prefs)"
-                eprintln!("Preferences file written.");
-            }
-            println!("{}", prefs_path.to_string_lossy());
-            return Ok(());
-        }
-        if prefs_path.exists() {
-            let prefs = Config::from_path(&prefs_path)?;
-            info!("prefs: {:#?}", &prefs);
-            settings.apply_config(&prefs);
+    if args.prefs {
+        let prefs_path =
+            bacon_prefs_path().ok_or(anyhow!("No preferences location known for this system."))?;
+        if !prefs_path.exists() {
+            fs::create_dir_all(prefs_path.parent().unwrap())?;
+            fs::write(&prefs_path, DEFAULT_PREFS.trim_start())?;
+            // written to stderr to allow initialization with commands like
+            //  $EDITOR "$(bacon --prefs)"
+            eprintln!("Preferences file written.");
         }
-    }
-
-    if let Some(config) = Config::from_env("BACON_PREFS")? {
-        settings.apply_config(&config);
+        println!("{}", prefs_path.to_string_lossy());
+        return Ok(());
     }
 
     let location = MissionLocation::new(&args)?;
     info!("mission location: {:#?}", &location);
 
-    let workspace_config_path = location.workspace_config_path();
-    let package_config_path = location.package_config_path();
-
-    if package_config_path != workspace_config_path {
-        if workspace_config_path.exists() {
-            info!("loading workspace level bacon.toml");
-            let workspace_config = Config::from_path(&workspace_config_path)?;
-            settings.apply_config(&workspace_config);
-        }
-    }
-
     if args.init {
+        let package_config_path = location.package_config_path();
         if !package_config_path.exists() {
             fs::write(&package_config_path, DEFAULT_PACKAGE_CONFIG.trim_start())?;
             eprintln!("bacon project configuration file written.");
@@ -101,21 +72,8 @@ pub fn run() -> anyhow::Result<()> {
         println!("{}", package_config_path.to_string_lossy());
         return Ok(());
     }
-    if package_config_path.exists() {
-        let config = Config::from_path(&package_config_path)?;
-        settings.apply_config(&config);
-    }
-
-    if let Some(config) = Config::from_env("BACON_CONFIG")? {
-        settings.apply_config(&config);
-    }
-
-    // args are applied after prefs, and package config so that they can override them
-    settings.apply_args(&args);
 
-    settings.check()?;
-
-    info!("settings: {:#?}", &settings);
+    let settings = Settings::read(&args, &location)?;
 
     if args.list_jobs {
         print_jobs(&settings);
@@ -128,41 +86,7 @@ pub fn run() -> anyhow::Result<()> {
     #[cfg(windows)]
     w.queue(EnableMouseCapture)?;
     w.flush()?;
-    let event_source = EventSource::with_options(EventSourceOptions {
-        combine_keys: false,
-        ..Default::default()
-    })?;
-    let mut job_stack = JobStack::new(&settings);
-    let mut next_job = JobRef::Initial;
-    let mut result = Ok(());
-    #[allow(clippy::while_let_loop)]
-    loop {
-        let (concrete_job_ref, job) = match job_stack.pick_job(&next_job) {
-            Err(e) => {
-                result = Err(e);
-                break;
-            }
-            Ok(Some(t)) => t,
-            Ok(None) => {
-                break;
-            }
-        };
-        let r = Mission::new(&location, concrete_job_ref, job, &settings)
-            .and_then(|mission| app::run(&mut w, mission, &event_source));
-        match r {
-            Ok(Some(job_ref)) => {
-                next_job = job_ref;
-            }
-            Ok(None) => {
-                break;
-            }
-            Err(e) => {
-                result = Err(e);
-                break;
-            }
-        }
-    }
-
+    let result = app::run(&mut w, settings, &args, location);
     #[cfg(windows)]
     w.queue(DisableMouseCapture)?;
     w.queue(cursor::Show)?;
diff --git a/src/conf/config.rs b/src/conf/config.rs
index 0dce6de..a1a4303 100644
--- a/src/conf/config.rs
+++ b/src/conf/config.rs
@@ -75,27 +75,6 @@ impl Config {
         }
         Ok(conf)
     }
-    pub fn from_env(env_var_name: &str) -> Result<Option<Self>> {
-        let Some(path) = std::env::var_os(env_var_name) else {
-            return Ok(None);
-        };
-        let path = Path::new(&path);
-        if !path.exists() {
-            // some users may want to use an env var to point to a file that may not always exist
-            // so we don't bail here
-            warn!(
-                "Env var {:?} points to file {:?} which does not exist",
-                env_var_name, path
-            );
-            return Ok(None);
-        }
-        let config = Self::from_path(path)?;
-        debug!(
-            "Loaded config at {:?} as specified in env var {:?}",
-            path, env_var_name
-        );
-        Ok(Some(config))
-    }
     pub fn default_package_config() -> Self {
         toml::from_str(DEFAULT_PACKAGE_CONFIG).unwrap()
     }
diff --git a/src/conf/mod.rs b/src/conf/mod.rs
index 7717624..21c7474 100644
--- a/src/conf/mod.rs
+++ b/src/conf/mod.rs
@@ -14,3 +14,33 @@ pub use {
     keybindings::*,
     settings::*,
 };
+
+use std::path::{
+    Path,
+    PathBuf,
+};
+
+/// If the system can manage application preferences, return the
+/// canonical path to the bacon preferences file
+pub fn bacon_prefs_path() -> Option<std::path::PathBuf> {
+    directories_next::ProjectDirs::from("org", "dystroy", "bacon")
+        .map(|project_dir| project_dir.config_dir().join("prefs.toml"))
+}
+
+/// Return the path given by the env var, if it exists (doesn't check whether
+/// it's a correct configuration file)
+pub fn config_path_from_env(env_var_name: &str) -> Option<PathBuf> {
+    let path = std::env::var_os(env_var_name)?;
+    let path = Path::new(&path);
+    if path.exists() {
+        Some(path.to_path_buf())
+    } else {
+        // some users may want to use an env var to point to a file that may not always exist
+        // so we don't throw an error here
+        warn!(
+            "Env var {:?} points to file {:?} which does not exist",
+            env_var_name, path
+        );
+        None
+    }
+}
diff --git a/src/conf/settings.rs b/src/conf/settings.rs
index 0cacb48..b2476b9 100644
--- a/src/conf/settings.rs
+++ b/src/conf/settings.rs
@@ -3,6 +3,7 @@ use {
     anyhow::*,
     std::{
         collections::HashMap,
+        path::PathBuf,
         time::Duration,
     },
 };
@@ -35,6 +36,9 @@ pub struct Settings {
     pub on_change_strategy: Option<OnChangeStrategy>,
     pub ignored_lines: Option<Vec<LinePattern>>,
     pub grace_period: Period,
+    /// Path of the files which were used to build the settings
+    /// (note that not all settings come from files)
+    pub config_files: Vec<PathBuf>,
 }
 
 impl Default for Settings {
@@ -58,12 +62,86 @@ impl Default for Settings {
             on_change_strategy: None,
             ignored_lines: Default::default(),
             grace_period: Duration::from_millis(5).into(),
+            config_files: Default::default(),
         }
     }
 }
 
 impl Settings {
-    /// Apply one of the configuration element, overriding
+    /// Read the settings from all configuration files and arguments.
+    ///
+    ///
+    /// Hardcoded defaults are overriden by the following configuration elements, in order:
+    /// * the global `prefs.toml`
+    /// * the file whose path is in environment variable `BACON_PREFS`
+    /// * the workspace level `bacon.toml` file
+    /// * the package level `bacon.toml` file
+    /// * the file whose path is in environment variable `BACON_CONFIG`
+    /// * args given as arguments, coming from the cli call
+    pub fn read(
+        args: &Args,
+        location: &MissionLocation,
+    ) -> Result<Self> {
+        let mut settings = Settings::default();
+
+        let default_package_config = Config::default_package_config();
+        settings.apply_config(&default_package_config);
+
+        if let Some(prefs_path) = bacon_prefs_path() {
+            if prefs_path.exists() {
+                let prefs = Config::from_path(&prefs_path)?;
+                info!("prefs: {:#?}", &prefs);
+                settings.register_config_file(prefs_path);
+                settings.apply_config(&prefs);
+            }
+        }
+
+        if let Some(config_path) = config_path_from_env("BACON_PREFS") {
+            let config = Config::from_path(&config_path)?;
+            info!("config from env: {:#?}", &config);
+            settings.register_config_file(config_path);
+            settings.apply_config(&config);
+        }
+
+        let workspace_config_path = location.workspace_config_path();
+        let package_config_path = location.package_config_path();
+
+        if package_config_path != workspace_config_path {
+            if workspace_config_path.exists() {
+                info!("loading workspace level bacon.toml");
+                let workspace_config = Config::from_path(&workspace_config_path)?;
+                settings.register_config_file(workspace_config_path);
+                settings.apply_config(&workspace_config);
+            }
+        }
+
+        if package_config_path.exists() {
+            let config = Config::from_path(&package_config_path)?;
+            settings.register_config_file(package_config_path);
+            settings.apply_config(&config);
+        }
+
+        if let Some(config_path) = config_path_from_env("BACON_CONFIG") {
+            let config = Config::from_path(&config_path)?;
+            info!("config from env: {:#?}", &config);
+            settings.register_config_file(config_path);
+            settings.apply_config(&config);
+        }
+
+        settings.apply_args(args);
+        settings.check()?;
+        info!("settings: {:#?}", &settings);
+        Ok(settings)
+    }
+
+    pub fn register_config_file(
+        &mut self,
+        path: PathBuf,
+    ) {
+        self.config_files.push(path);
+    }
+
+    /// Apply one of the configuration elements, overriding
     /// defaults and previously applied configuration elements
     pub fn apply_config(
         &mut self,
@@ -161,6 +239,7 @@ impl Settings {
         self.additional_job_args
             .clone_from(&args.additional_job_args);
     }
+
     pub fn check(&self) -> Result<()> {
         if self.jobs.is_empty() {
             bail!("Invalid configuration : no job found");
diff --git a/src/internal.rs b/src/internal.rs
index d2c9b0b..0d93cf1 100644
--- a/src/internal.rs
+++ b/src/internal.rs
@@ -23,6 +23,7 @@ pub enum Internal {
     Quit,
     ReRun,
     Refresh, // clear and rerun
+    ReloadConfig,
     ScopeToFailures,
     Scroll(ScrollCommand),
     ToggleBacktrace(&'static str),
@@ -43,6 +44,7 @@ impl Internal {
             Self::Quit => "quit".to_string(),
             Self::ReRun => "run current job again".to_string(),
             Self::Refresh => "clear then run current job again".to_string(),
+            Self::ReloadConfig => "reload configuration files".to_string(),
             Self::ScopeToFailures => "scope to failures".to_string(),
             Self::Scroll(scroll_command) => scroll_command.doc(),
             Self::ToggleBacktrace(level) => format!("toggle backtrace ({level})"),
@@ -67,6 +69,7 @@ impl fmt::Display for Internal {
             Self::Quit => write!(f, "quit"),
             Self::ReRun => write!(f, "rerun"),
             Self::Refresh => write!(f, "refresh"),
+            Self::ReloadConfig => write!(f, "reload-config"),
             Self::ScopeToFailures => write!(f, "scope-to-failures"),
             Self::Scroll(scroll_command) => scroll_command.fmt(f),
             Self::ToggleBacktrace(level) => write!(f, "toggle-backtrace({level})"),
@@ -89,6 +92,7 @@ impl std::str::FromStr for Internal {
             "help" => Ok(Self::Help),
             "quit" => Ok(Self::Quit),
             "refresh" => Ok(Self::Refresh),
+            "reload-config" => Ok(Self::ReloadConfig),
             "rerun" => Ok(Self::ReRun),
             "scope-to-failures" => Ok(Self::ScopeToFailures),
             "toggle-raw-output" => Ok(Self::ToggleRawOutput),
@@ -134,6 +138,7 @@ fn test_internal_string_round_trip() {
         Internal::Pause,
         Internal::Quit,
         Internal::ReRun,
+        Internal::ReloadConfig,
         Internal::ScopeToFailures,
         Internal::Scroll(ScrollCommand::Pages(-3)),
         Internal::Scroll(ScrollCommand::Top),
diff --git a/src/jobs/concrete_job_ref.rs b/src/jobs/concrete_job_ref.rs
new file mode 100644
index 0000000..af4ffff
--- /dev/null
+++ b/src/jobs/concrete_job_ref.rs
@@ -0,0 +1,136 @@
+use {
+    crate::*,
+    lazy_regex::*,
+    serde::{
+        Deserialize,
+        Deserializer,
+        Serialize,
+        Serializer,
+        de,
+    },
+    std::{
+        fmt,
+        str::FromStr,
+    },
+};
+
+/// A "concrete" job ref is one which can be used from the start, without
+/// referring to the job stack
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct ConcreteJobRef {
+    pub name_or_alias: NameOrAlias,
+    pub scope: Scope,
+}
+
+impl ConcreteJobRef {
+    pub fn from_job_name<S: Into<String>>(s: S) -> Self {
+        Self {
+            name_or_alias: NameOrAlias::Name(s.into()),
+            scope: Default::default(),
+        }
+    }
+    pub fn badge_label(&self) -> String {
+        let mut s = String::new();
+        match &self.name_or_alias {
+            NameOrAlias::Name(name) => {
+                s.push_str(name);
+            }
+            NameOrAlias::Alias(alias) => {
+                s.push_str(alias);
+            }
+        }
+        if self.scope.has_tests() {
+            s.push_str(" (scoped)");
+        }
+        s
+    }
+    pub fn with_scope(
+        mut self,
+        scope: Scope,
+    ) -> Self {
+        self.scope = scope;
+        self
+    }
+}
+
+impl Default for ConcreteJobRef {
+    fn default() -> Self {
+        Self {
+            name_or_alias: NameOrAlias::Name("check".to_string()),
+            scope: Default::default(),
+        }
+    }
+}
+
+impl fmt::Display for ConcreteJobRef {
+    fn fmt(
+        &self,
+        f: &mut fmt::Formatter,
+    ) -> fmt::Result {
+        match &self.name_or_alias {
+            NameOrAlias::Alias(alias) => write!(f, "alias:{alias}")?,
+            NameOrAlias::Name(name) => write!(f, "{name}")?,
+        }
+        if self.scope.has_tests() {
+            write!(f, "({})", self.scope.tests.join(","))?;
+        }
+        Ok(())
+    }
+}
+impl FromStr for ConcreteJobRef {
+    type Err = &'static str;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        if s.is_empty() {
+            Err("empty job name")
+        } else {
+            Ok(s.into())
+        }
+    }
+}
+impl Serialize for ConcreteJobRef {
+    fn serialize<S>(
+        &self,
+        serializer: S,
+    ) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        serializer.serialize_str(&self.to_string())
+    }
+}
+impl<'de> Deserialize<'de> for ConcreteJobRef {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        let s = String::deserialize(deserializer)?;
+        FromStr::from_str(&s).map_err(de::Error::custom)
+    }
+}
+
+impl From<&str> for ConcreteJobRef {
+    fn from(str_entry: &str) -> Self {
+        let Some((_, alias_prefix, name_or_alias, scope)) =
+            regex_captures!(r"^(alias:)?([^\(\)]+)(?:\(([^\)]+)\))?$", str_entry,)
+        else {
+            warn!("unexpected job ref: {:?}", str_entry);
+            return Self::from_job_name(str_entry.to_string());
+        };
+        let name_or_alias = if alias_prefix.is_empty() {
+            NameOrAlias::Name(name_or_alias.to_string())
+        } else {
+            NameOrAlias::Alias(name_or_alias.to_string())
+        };
+        let scope = Scope {
+            tests: scope
+                .split(',')
+                .filter(|t| !t.trim().is_empty())
+                .map(|s| s.to_string())
+                .collect(),
+        };
+        Self {
+            name_or_alias,
+            scope,
+        }
+    }
+}
diff --git a/src/jobs/job_ref.rs b/src/jobs/job_ref.rs
index 7b52d9a..9e0fcbd 100644
--- a/src/jobs/job_ref.rs
+++ b/src/jobs/job_ref.rs
@@ -1,17 +1,7 @@
 use {
     crate::*,
     lazy_regex::*,
-    serde::{
-        Deserialize,
-        Deserializer,
-        Serialize,
-        Serializer,
-        de,
-    },
-    std::{
-        fmt,
-        str::FromStr,
-    },
+    std::fmt,
 };
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -29,42 +19,15 @@ impl JobRef {
     }
 }
 
-/// A "concrete" job ref is one which can be used from the start, without
-/// referring to the job stack
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-pub struct ConcreteJobRef {
-    pub name_or_alias: NameOrAlias,
-    pub scope: Scope,
+impl From<Scope> for JobRef {
+    fn from(scope: Scope) -> Self {
+        Self::Scope(scope)
+    }
 }
 
-impl ConcreteJobRef {
-    pub fn from_job_name<S: Into<String>>(s: S) -> Self {
-        Self {
-            name_or_alias: NameOrAlias::Name(s.into()),
-            scope: Default::default(),
-        }
-    }
-    pub fn badge_label(&self) -> String {
-        let mut s = String::new();
-        match &self.name_or_alias {
-            NameOrAlias::Name(name) => {
-                s.push_str(name);
-            }
-            NameOrAlias::Alias(alias) => {
-                s.push_str(alias);
-            }
-        }
-        if self.scope.has_tests() {
-            s.push_str(" (scoped)");
-        }
-        s
-    }
-    pub fn with_scope(
-        mut self,
-        scope: Scope,
-    ) -> Self {
-        self.scope = scope;
-        self
+impl From<ConcreteJobRef> for JobRef {
+    fn from(concrete: ConcreteJobRef) -> Self {
+        Self::Concrete(concrete)
     }
 }
 
@@ -74,88 +37,6 @@ pub enum NameOrAlias {
     Alias(String),
 }
 
-impl Default for ConcreteJobRef {
-    fn default() -> Self {
-        Self {
-            name_or_alias: NameOrAlias::Name("check".to_string()),
-            scope: Default::default(),
-        }
-    }
-}
-
-impl fmt::Display for ConcreteJobRef {
-    fn fmt(
-        &self,
-        f: &mut fmt::Formatter,
-    ) -> fmt::Result {
-        match &self.name_or_alias {
-            NameOrAlias::Alias(alias) => write!(f, "alias:{alias}")?,
-            NameOrAlias::Name(name) => write!(f, "{name}")?,
-        }
-        if self.scope.has_tests() {
-            write!(f, "({})", self.scope.tests.join(","))?;
-        }
-        Ok(())
-    }
-}
-impl FromStr for ConcreteJobRef {
-    type Err = &'static str;
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        if s.is_empty() {
-            Err("empty job name")
-        } else {
-            Ok(s.into())
-        }
-    }
-}
-impl Serialize for ConcreteJobRef {
-    fn serialize<S>(
-        &self,
-        serializer: S,
-    ) -> Result<S::Ok, S::Error>
-    where
-        S: Serializer,
-    {
-        serializer.serialize_str(&self.to_string())
-    }
-}
-impl<'de> Deserialize<'de> for ConcreteJobRef {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        let s = String::deserialize(deserializer)?;
-        FromStr::from_str(&s).map_err(de::Error::custom)
-    }
-}
-
-impl From<&str> for ConcreteJobRef {
-    fn from(str_entry: &str) -> Self {
-        let Some((_, alias_prefix, name_or_alias, scope)) =
-            regex_captures!(r"^(alias:)?([^\(\)]+)(?:\(([^\)]+)\))?$", str_entry,)
-        else {
-            warn!("unexpected job ref: {:?}", str_entry);
-            return Self::from_job_name(str_entry.to_string());
-        };
-        let name_or_alias = if alias_prefix.is_empty() {
-            NameOrAlias::Name(name_or_alias.to_string())
-        } else {
-            NameOrAlias::Alias(name_or_alias.to_string())
-        };
-        let scope = Scope {
-            tests: scope
-                .split(',')
-                .filter(|t| !t.trim().is_empty())
-                .map(|s| s.to_string())
-                .collect(),
-        };
-        Self {
-            name_or_alias,
-            scope,
-        }
-    }
-}
-
 impl fmt::Display for JobRef {
     fn fmt(
         &self,
diff --git a/src/jobs/job_stack.rs b/src/jobs/job_stack.rs
index b6e6fd1..1c5d4f5 100644
--- a/src/jobs/job_stack.rs
+++ b/src/jobs/job_stack.rs
@@ -8,37 +8,28 @@ use {
 
 /// The stack of jobs that bacon ran, allowing to get back to the previous one,
 /// or to scope the current one
-pub struct JobStack<'c> {
-    settings: &'c Settings,
+#[derive(Default)]
+pub struct JobStack {
     entries: Vec<ConcreteJobRef>,
 }
 
-impl<'c> JobStack<'c> {
-    pub fn new(settings: &'c Settings) -> Self {
-        Self {
-            settings,
-            entries: Vec::new(),
-        }
-    }
-
-    fn initial_job(&self) -> &ConcreteJobRef {
-        self.settings
-            .arg_job
-            .as_ref()
-            .unwrap_or(&self.settings.default_job)
-    }
-
+impl JobStack {
     /// Apply the job ref instruction to determine the job to run, updating the stack.
     ///
     /// When no job is returned, the application is supposed to quit.
     pub fn pick_job(
         &mut self,
         job_ref: &JobRef,
+        settings: &Settings,
     ) -> Result<Option<(ConcreteJobRef, Job)>> {
         debug!("picking job {job_ref:?}");
         let concrete = match job_ref {
-            JobRef::Default => self.settings.default_job.clone(),
-            JobRef::Initial => self.initial_job().clone(),
+            JobRef::Default => settings.default_job.clone(),
+            JobRef::Initial => settings
+                .arg_job
+                .as_ref()
+                .unwrap_or(&settings.default_job)
+                .clone(),
             JobRef::Previous => {
                 let current = self.entries.pop();
                 match self.entries.pop() {
@@ -70,9 +61,8 @@ impl<'c> JobStack<'c> {
             },
         };
         let job = match &concrete.name_or_alias {
-            NameOrAlias::Alias(alias) => Job::from_alias(alias, self.settings),
-            NameOrAlias::Name(name) => self
-                .settings
+            NameOrAlias::Alias(alias) => Job::from_alias(alias, settings),
+            NameOrAlias::Name(name) => settings
                 .jobs
                 .get(name)
                 .ok_or_else(|| anyhow!("job not found: {:?}", name))?
diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs
index e35fea6..ed5e152 100644
--- a/src/jobs/mod.rs
+++ b/src/jobs/mod.rs
@@ -1,9 +1,11 @@
+mod concrete_job_ref;
 mod job;
 mod job_ref;
 mod job_stack;
 mod scope;
 
 pub use {
+    concrete_job_ref::*,
     job::*,
     job_ref::*,
     job_stack::*,
diff --git a/src/lib.rs b/src/lib.rs
index 396e4c7..227878e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -17,6 +17,7 @@ mod result;
 mod scroll;
 mod state;
 mod tty;
+mod watcher;
 mod wrap;
 
 pub use {
@@ -38,6 +39,7 @@ pub use {
     scroll::*,
     state::*,
     tty::*,
+    watcher::*,
     wrap::*,
 };
 
diff --git a/src/messages.rs b/src/messages.rs
index 5af7a25..2bdd472 100644
--- a/src/messages.rs
+++ b/src/messages.rs
@@ -15,9 +15,9 @@ pub struct Message {
 impl Message {
     /// build a short message, typically to answer to a user action
     /// (thus when the user is looking at bacon)
-    pub fn short(markdown: String) -> Self {
+    pub fn short<S: Into<String>>(markdown: S) -> Self {
         Self {
-            markdown,
+            markdown: markdown.into(),
             display_start: None,
             display_duration: Duration::from_secs(5),
         }
diff --git a/src/mission.rs b/src/mission.rs
index 9c5790c..c87c4df 100644
--- a/src/mission.rs
+++ b/src/mission.rs
@@ -2,11 +2,6 @@ use {
     crate::*,
     anyhow::Result,
     lazy_regex::regex_replace_all,
-    notify::{
-        RecommendedWatcher,
-        RecursiveMode,
-        Watcher,
-    },
     rustc_hash::FxHashSet,
     std::path::PathBuf,
 };
@@ -22,8 +17,8 @@ pub struct Mission<'s> {
     pub cargo_execution_directory: PathBuf,
     pub workspace_root: PathBuf,
     pub job: Job,
-    files_to_watch: Vec<PathBuf>,
-    directories_to_watch: Vec<PathBuf>,
+    pub files_to_watch: Vec<PathBuf>,
+    pub directories_to_watch: Vec<PathBuf>,
     pub settings: &'s Settings,
 }
 
@@ -125,22 +120,6 @@ impl<'s> Mission<'s> {
         report.is_success(self.job.allow_warnings, self.job.allow_failures)
     }
 
-    /// configure the watcher with files and directories to watch
-    pub fn add_watchs(
-        &self,
-        watcher: &mut RecommendedWatcher,
-    ) -> Result<()> {
-        for file in &self.files_to_watch {
-            debug!("add watch file {:?}", file);
-            watcher.watch(file, RecursiveMode::NonRecursive)?;
-        }
-        for dir in &self.directories_to_watch {
-            debug!("add watch dir {:?}", dir);
-            watcher.watch(dir, RecursiveMode::Recursive)?;
-        }
-        Ok(())
-    }
-
     /// build (and doesn't call) the external cargo command
     pub fn get_command(&self) -> CommandBuilder {
         let mut command = if self.job.expand_env_vars {
diff --git a/src/state.rs b/src/state.rs
index ae40a08..df04807 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -518,9 +518,10 @@ impl<'s> AppState<'s> {
             message.display_start = Some(Instant::now());
         }
         goto(w, y)?;
+        let markdown = format!(" {}", message.markdown);
         self.status_skin.write_composite_fill(
             w,
-            Composite::from_inline(&message.markdown),
+            Composite::from_inline(&markdown),
             self.width.into(),
             Alignment::Left,
         )?;
diff --git a/src/watcher.rs b/src/watcher.rs
new file mode 100644
index 0000000..c2f9741
--- /dev/null
+++ b/src/watcher.rs
@@ -0,0 +1,92 @@
+use {
+    crate::*,
+    anyhow::Result,
+    crossbeam::channel::{
+        Receiver,
+        bounded,
+    },
+    notify::{
+        RecommendedWatcher,
+        RecursiveMode,
+        Watcher as NotifyWatcher,
+        event::{
+            AccessKind,
+            AccessMode,
+            DataChange,
+            EventKind,
+            ModifyKind,
+        },
+    },
+    std::path::PathBuf,
+};
+
+/// A file watcher, providing a channel to receive notifications
+pub struct Watcher {
+    _notify_watcher: RecommendedWatcher,
+    pub receiver: Receiver<()>,
+}
+
+impl Watcher {
+    pub fn new(
+        files_to_watch: &[PathBuf],
+        directories_to_watch: &[PathBuf],
+        mut ignorer: Option<Ignorer>,
+    ) -> Result<Self> {
+        let (sender, receiver) = bounded(0);
+        let mut notify_watcher =
+            notify::recommended_watcher(move |res: notify::Result<notify::Event>| match res {
+                Ok(we) => {
+                    match we.kind {
+                        EventKind::Modify(ModifyKind::Metadata(_)) => {
+                            debug!("ignoring metadata change");
+                            return; // useless event
+                        }
+                        EventKind::Modify(ModifyKind::Data(DataChange::Any)) => {
+                            debug!("ignoring 'any' data change");
+                            return; // probably useless event with no real change
+                        }
+                        EventKind::Access(AccessKind::Close(AccessMode::Write)) => {
+                            debug!("close write event: {we:?}");
+                        }
+                        EventKind::Access(_) => {
+                            debug!("ignoring access event: {we:?}");
+                            return; // probably useless event
+                        }
+                        _ => {
+                            debug!("notify event: {we:?}");
+                        }
+                    }
+                    if let Some(ignorer) = ignorer.as_mut() {
+                        match time!(Info, ignorer.excludes_all(&we.paths)) {
+                            Ok(true) => {
+                                debug!("all excluded");
+                                return;
+                            }
+                            Ok(false) => {
+                                debug!("at least one is included");
+                            }
+                            Err(e) => {
+                                warn!("exclusion check failed: {e}");
+                            }
+                        }
+                    }
+                    if let Err(e) = sender.send(()) {
+                        debug!("error when notifying on notify event: {}", e);
+                    }
+                }
+                Err(e) => warn!("watch error: {:?}", e),
+            })?;
+        for file in files_to_watch {
+            debug!("add watch file {:?}", file);
+            notify_watcher.watch(file, RecursiveMode::NonRecursive)?;
+        }
+        for dir in directories_to_watch {
+            debug!("add watch dir {:?}", dir);
+            notify_watcher.watch(dir, RecursiveMode::Recursive)?;
+        }
+        Ok(Self {
+            _notify_watcher: notify_watcher,
+            receiver,
+        })
+    }
+}
diff --git a/website/docs/config.md b/website/docs/config.md
index 02f008b..51f763d 100644
--- a/website/docs/config.md
+++ b/website/docs/config.md
@@ -20,6 +20,8 @@ When you modified those files and bacon evolved since, you may want to have a lo
 * [Current default prefs.toml](https://raw.githubusercontent.com/Canop/bacon/main/defaults/default-prefs.toml)
 * [Current default bacon.toml](https://raw.githubusercontent.com/Canop/bacon/main/defaults/default-bacon.toml)
 
+Bacon watches those files and reload them when necessary, so you don't have to relaunch it if you add a key-binding, or a job, or [an allowed lint](../cookbook/#configure-clippy-lints) in your clippy job.
+
 ## Global Preferences
 
 `bacon --prefs` creates the preferences file if it doesn't exist and returns its path (which is system dependent).
@@ -178,6 +180,7 @@ back | <kbd>Esc</kbd> | get back to the previous page or job
 help | <kbd>h</kbd> or <kbd>?</kbd> | open the help page
 quit | <kbd>q</kbd> or <kbd>ctrl</kbd><kbd>q</kbd> or <kbd>ctrl</kbd><kbd>c</kbd> | quit
 refresh | <kbd>F5</kbd> | clear output then run current job again
+reload-config | | reload all configuration files
 rerun |  | run current job again
 toggle-raw-output |  | display the untransformed command output
 toggle-backtrace(level) | <kbd>b</kbd> | enable rust backtrace, level is either `1` or `full`


Test patch: