From fa57e21e11292b770698cf98fa48566a6518f95e Mon Sep 17 00:00:00 2001 From: Kalle Virtaneva Date: Tue, 10 Mar 2026 15:45:40 -0400 Subject: [PATCH 1/4] fix(flags): add a new experimental-features flag to gate new features --- src/cli.rs | 21 ++++++++++++++++++--- src/ghci/mod.rs | 3 ++- src/main.rs | 3 ++- src/tracing/mod.rs | 3 ++- src/tui/mod.rs | 2 +- tests/clap_markdown.rs | 11 ++++++++--- 6 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 6332ec4b..419f4fc0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -13,6 +13,13 @@ use crate::clonable_command::ClonableCommand; use crate::ignore::GlobMatcher; use crate::normal_path::NormalPath; +/// An experimental feature that can be enabled with `--experimental-features`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum ExperimentalFeature { + /// Enable TUI mode. + Tui, +} + /// Ghciwatch loads a GHCi session for a Haskell project and reloads it /// when source files change. #[derive(Debug, Clone, Parser)] @@ -62,9 +69,12 @@ pub struct Opts { #[arg(long)] pub no_interrupt_reloads: bool, - /// Enable TUI mode (experimental). - #[arg(long, hide = true)] - pub tui: bool, + /// Enable experimental features. These features are unsupported and may change or be removed + /// without notice. + /// + /// Can be given multiple times. + #[arg(long = "experimental-features", value_name = "FEATURE", hide = true)] + pub experimental_features: Vec, /// Generate Markdown CLI documentation. #[cfg(feature = "clap-markdown")] @@ -211,6 +221,11 @@ pub struct LoggingOpts { } impl Opts { + /// Check whether a given experimental feature has been enabled. + pub fn has_experimental_feature(&self, feature: ExperimentalFeature) -> bool { + self.experimental_features.contains(&feature) + } + /// Perform late initialization of the command-line arguments. If `init` isn't called before /// the arguments are used, the behavior is undefined. pub fn init(&mut self) -> miette::Result<()> { diff --git a/src/ghci/mod.rs b/src/ghci/mod.rs index 6eb2b4f8..3f1b5220 100644 --- a/src/ghci/mod.rs +++ b/src/ghci/mod.rs @@ -71,6 +71,7 @@ use loaded_module::LoadedModule; use crate::aho_corasick::AhoCorasickExt; use crate::buffers::LINE_BUFFER_CAPACITY; +use crate::cli::ExperimentalFeature; use crate::cli::Opts; use crate::clonable_command::ClonableCommand; use crate::event_filter::FileEvent; @@ -142,7 +143,7 @@ impl GhciOpts { let stderr_writer; let tui_reader; - if opts.tui { + if opts.has_experimental_feature(ExperimentalFeature::Tui) { let (tui_writer, tui_reader_inner) = tokio::io::duplex(GHCI_BUFFER_CAPACITY); let tui_writer = GhciWriter::duplex_stream(tui_writer); stdout_writer = tui_writer.clone(); diff --git a/src/main.rs b/src/main.rs index d0cfb478..18848d69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use std::time::Duration; use clap::CommandFactory; use clap::Parser; use ghciwatch::cli; +use ghciwatch::cli::ExperimentalFeature; use ghciwatch::run_ghci; use ghciwatch::run_tui; use ghciwatch::run_watcher; @@ -58,7 +59,7 @@ async fn main() -> miette::Result<()> { let mut manager = ShutdownManager::with_timeout(Duration::from_secs(1)); - if opts.tui { + if opts.has_experimental_feature(ExperimentalFeature::Tui) { let tracing_reader = maybe_tracing_reader.expect("`tracing_reader` must be present if `tui` is given"); let ghci_reader = diff --git a/src/tracing/mod.rs b/src/tracing/mod.rs index 40a3e465..9dc5b695 100644 --- a/src/tracing/mod.rs +++ b/src/tracing/mod.rs @@ -15,6 +15,7 @@ use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::EnvFilter; use tracing_subscriber::Layer; +use crate::cli::ExperimentalFeature; use crate::cli::Opts; /// Options for initializing the [`tracing`] logging framework. This is like a lower-effort builder @@ -40,7 +41,7 @@ impl<'opts> TracingOpts<'opts> { filter_directives: &opts.logging.log_filter, trace_spans: &opts.logging.trace_spans, json_log_path: opts.logging.log_json.as_deref(), - tui: if opts.tui { + tui: if opts.has_experimental_feature(ExperimentalFeature::Tui) { Some(tokio::io::duplex(crate::buffers::TRACING_BUFFER_CAPACITY)) } else { None diff --git a/src/tui/mod.rs b/src/tui/mod.rs index e3a56f00..d5446184 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -227,7 +227,7 @@ pub async fn run_tui( let mut event_stream = EventStream::new(); - tracing::warn!("`--tui` mode is experimental and may contain bugs or change drastically in future releases."); + tracing::warn!("`--experimental-features tui` mode is experimental and may contain bugs or change drastically in future releases."); while !tui.quit { tui.render()?; diff --git a/tests/clap_markdown.rs b/tests/clap_markdown.rs index b2b5cb01..8306a770 100644 --- a/tests/clap_markdown.rs +++ b/tests/clap_markdown.rs @@ -35,9 +35,9 @@ fn test_clap_markdown() { #[arg(long, alias = "allow-eval")] pub enable_eval: bool, - /// Enable TUI mode (experimental). - #[arg(long, hide = true)] - pub tui: bool, + /// Enable experimental features. + #[arg(long = "experimental-features", value_name = "FEATURE", hide = true)] + pub experimental_features: Vec, /// Options to modify file watching. #[command(flatten)] @@ -48,6 +48,11 @@ fn test_clap_markdown() { pub logging: LoggingOpts, } + #[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] + pub enum ExperimentalFeature { + Tui, + } + /// Options for watching files. #[derive(Debug, Clone, clap::Args)] #[clap(next_help_heading = "File watching options")] From 59ed5d3b618db05433a538641263eb3a428a3702 Mon Sep 17 00:00:00 2001 From: Kalle Virtaneva Date: Tue, 10 Mar 2026 17:50:53 -0400 Subject: [PATCH 2/4] refactor: review feedback --- src/cli.rs | 5 +++++ src/main.rs | 14 ++++++++++++++ src/tui/mod.rs | 2 -- tests/experimental_features.rs | 29 +++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 tests/experimental_features.rs diff --git a/src/cli.rs b/src/cli.rs index 419f4fc0..e1a2d469 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -76,6 +76,11 @@ pub struct Opts { #[arg(long = "experimental-features", value_name = "FEATURE", hide = true)] pub experimental_features: Vec, + /// Deprecated: use `--experimental-features tui` instead. + // TODO: Remove after 2026-06-01. + #[arg(long, hide = true)] + pub tui: bool, + /// Generate Markdown CLI documentation. #[cfg(feature = "clap-markdown")] #[arg(long, hide = true)] diff --git a/src/main.rs b/src/main.rs index 18848d69..4c9a7270 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use ghciwatch::GhciOpts; use ghciwatch::ShutdownManager; use ghciwatch::TracingOpts; use ghciwatch::WatcherOpts; +use miette::miette; use tokio::sync::mpsc; #[tokio::main] @@ -24,8 +25,21 @@ async fn main() -> miette::Result<()> { miette::set_panic_hook(); let mut opts = cli::Opts::parse(); opts.init()?; + + if opts.tui { + return Err(miette!( + "`--tui` has been removed. Please use `--experimental-features tui` instead." + )); + } + let (maybe_tracing_reader, _tracing_guard) = TracingOpts::from_cli(&opts).install()?; + if !opts.experimental_features.is_empty() { + tracing::warn!( + "`--experimental-features` may contain bugs or change drastically in future releases." + ); + } + #[cfg(feature = "clap-markdown")] if opts.generate_markdown_help { println!("{}", ghciwatch::clap_markdown::help_markdown::()); diff --git a/src/tui/mod.rs b/src/tui/mod.rs index d5446184..bbf7a37d 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -227,8 +227,6 @@ pub async fn run_tui( let mut event_stream = EventStream::new(); - tracing::warn!("`--experimental-features tui` mode is experimental and may contain bugs or change drastically in future releases."); - while !tui.quit { tui.render()?; diff --git a/tests/experimental_features.rs b/tests/experimental_features.rs new file mode 100644 index 00000000..0a277136 --- /dev/null +++ b/tests/experimental_features.rs @@ -0,0 +1,29 @@ +use test_harness::test; +use test_harness::GhciWatchBuilder; + +/// Invalid experimental feature values should produce an error immediately. +#[test] +async fn invalid_experimental_feature_errors() { + let result = GhciWatchBuilder::new("tests/data/simple") + .with_args(["--experimental-features", "asdflkj"]) + .start() + .await; + assert!( + result.is_err(), + "ghciwatch should error on invalid experimental feature" + ); +} + +/// Enabling experimental features should emit a warning log. +#[test] +async fn experimental_features_emits_warning() { + let mut session = GhciWatchBuilder::new("tests/data/simple") + .with_args(["--experimental-features", "tui"]) + .start() + .await + .expect("ghciwatch starts"); + session + .wait_for_log("--experimental-features.*may contain bugs") + .await + .unwrap(); +} From 125f1c7867b518a929dbd5b6be6023d3a9c0f4ad Mon Sep 17 00:00:00 2001 From: Kalle Virtaneva Date: Tue, 10 Mar 2026 18:15:19 -0400 Subject: [PATCH 3/4] test: change up approach --- tests/experimental_features.rs | 38 +++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/tests/experimental_features.rs b/tests/experimental_features.rs index 0a277136..a94d8fc7 100644 --- a/tests/experimental_features.rs +++ b/tests/experimental_features.rs @@ -1,5 +1,8 @@ +use std::path::PathBuf; + use test_harness::test; use test_harness::GhciWatchBuilder; +use tokio::process::Command; /// Invalid experimental feature values should produce an error immediately. #[test] @@ -14,16 +17,31 @@ async fn invalid_experimental_feature_errors() { ); } -/// Enabling experimental features should emit a warning log. -#[test] +/// Runs ghciwatch directly (not via `GhciWatchBuilder`) because TUI mode +/// crashes without a terminal, exiting before the test harness can connect. +#[tokio::test] async fn experimental_features_emits_warning() { - let mut session = GhciWatchBuilder::new("tests/data/simple") - .with_args(["--experimental-features", "tui"]) - .start() - .await - .expect("ghciwatch starts"); - session - .wait_for_log("--experimental-features.*may contain bugs") + let log_dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("experimental-warning"); + std::fs::create_dir_all(&log_dir).expect("can create log dir"); + let log_path = log_dir.join("ghciwatch.json"); + + let _output = Command::new(env!("CARGO_BIN_EXE_ghciwatch")) + .args(["--experimental-features", "tui"]) + .arg("--log-json") + .arg(&log_path) + .args(["--watch", "src"]) + .current_dir("tests/data/simple") + .output() .await - .unwrap(); + .expect("can run ghciwatch"); + + let log_contents = std::fs::read_to_string(&log_path).unwrap_or_else(|e| { + panic!("can read log file at {}: {e}", log_path.display()) + }); + assert!( + log_contents.contains("--experimental-features"), + "warning about experimental features should appear in JSON log" + ); + + let _ = std::fs::remove_dir_all(&log_dir); } From f65134179eedf02ac55b4af7e4ea07f48364e729 Mon Sep 17 00:00:00 2001 From: Kalle Virtaneva Date: Tue, 10 Mar 2026 18:19:40 -0400 Subject: [PATCH 4/4] chore: fmt --- tests/experimental_features.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/experimental_features.rs b/tests/experimental_features.rs index a94d8fc7..03c82738 100644 --- a/tests/experimental_features.rs +++ b/tests/experimental_features.rs @@ -35,9 +35,8 @@ async fn experimental_features_emits_warning() { .await .expect("can run ghciwatch"); - let log_contents = std::fs::read_to_string(&log_path).unwrap_or_else(|e| { - panic!("can read log file at {}: {e}", log_path.display()) - }); + let log_contents = std::fs::read_to_string(&log_path) + .unwrap_or_else(|e| panic!("can read log file at {}: {e}", log_path.display())); assert!( log_contents.contains("--experimental-features"), "warning about experimental features should appear in JSON log"