From a91e8dd9edb7266e242996a7abf3ff6dfb61f1cc Mon Sep 17 00:00:00 2001 From: Emma <817422+Pajn@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:51:02 +0100 Subject: [PATCH 1/3] Port bin to argh so we get help text --- Cargo.lock | 32 +++ crates/wisp-bin/Cargo.toml | 1 + crates/wisp-bin/src/main.rs | 427 ++++++++++++++++++++++++--------- crates/wisp-bin/tests/smoke.rs | 80 +++++- 4 files changed, 417 insertions(+), 123 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e75b5e..7120e13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,37 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "argh" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211818e820cda9ca6f167a64a5c808837366a6dfd807157c64c1304c486cd033" +dependencies = [ + "argh_derive", + "argh_shared", +] + +[[package]] +name = "argh_derive" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c442a9d18cef5dde467405d27d461d080d68972d6d0dfd0408265b6749ec427d" +dependencies = [ + "argh_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "argh_shared" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5ade012bac4db278517a0132c8c10c6427025868dca16c801087c28d5a411f1" +dependencies = [ + "serde", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -1156,6 +1187,7 @@ dependencies = [ name = "wisp-bin" version = "0.1.0" dependencies = [ + "argh", "crossterm", "ratatui", "wisp-app", diff --git a/crates/wisp-bin/Cargo.toml b/crates/wisp-bin/Cargo.toml index b30b513..87639e9 100644 --- a/crates/wisp-bin/Cargo.toml +++ b/crates/wisp-bin/Cargo.toml @@ -8,6 +8,7 @@ name = "wisp" path = "src/main.rs" [dependencies] +argh = "0.1" crossterm.workspace = true ratatui.workspace = true wisp-app = { path = "../wisp-app" } diff --git a/crates/wisp-bin/src/main.rs b/crates/wisp-bin/src/main.rs index d7c0354..b60e28d 100644 --- a/crates/wisp-bin/src/main.rs +++ b/crates/wisp-bin/src/main.rs @@ -6,13 +6,14 @@ use std::{ fs, io::stdout, path::{Path, PathBuf}, - process::Command, + process::Command as ProcessCommand, process::ExitCode, sync::{Arc, Mutex, mpsc}, thread, time::{Duration, Instant}, }; +use argh::{FromArgValue, FromArgs}; use crossterm::{ event::{self, Event}, execute, @@ -49,6 +50,135 @@ const STATUSLINE_REFRESH_HOOKS: &[&str] = &[ ]; const STATUSLINE_REFRESH_COMMAND: &str = "refresh-client -S"; +#[derive(Debug, FromArgs, PartialEq)] +/// Wisp is a tmux navigation workspace. +/// +/// Run `wisp help` to list available commands. +#[argh(help_triggers("-h", "--help", "help"))] +struct Cli { + #[argh(subcommand)] + command: Command, +} + +#[derive(Debug, FromArgs, PartialEq)] +#[argh(subcommand)] +enum Command { + Doctor(DoctorCommand), + PrintConfig(PrintConfigCommand), + Fullscreen(FullscreenCommand), + Popup(PopupCommandCli), + SidebarPopup(SidebarPopupCommand), + SidebarPane(SidebarPaneCommand), + Statusline(StatuslineGroupCommand), +} + +#[derive(Debug, FromArgs, PartialEq)] +#[argh(subcommand, name = "doctor")] +/// Print runtime diagnostics for tmux and zoxide. +struct DoctorCommand {} + +#[derive(Debug, FromArgs, PartialEq)] +#[argh(subcommand, name = "print-config")] +/// Print the resolved configuration. +struct PrintConfigCommand {} + +#[derive(Debug, FromArgs, PartialEq)] +#[argh(subcommand, name = "fullscreen")] +/// Open the main picker fullscreen in tmux. +struct FullscreenCommand {} + +#[derive(Debug, FromArgs, PartialEq)] +#[argh(subcommand, name = "popup")] +/// Open the main picker in a tmux popup. +struct PopupCommandCli {} + +#[derive(Debug, FromArgs, PartialEq)] +#[argh(subcommand, name = "sidebar-popup")] +/// Open the sidebar picker in a tmux popup. +struct SidebarPopupCommand {} + +#[derive(Debug, FromArgs, PartialEq)] +#[argh(subcommand, name = "sidebar-pane")] +/// Open the sidebar picker in a persistent tmux pane. +struct SidebarPaneCommand {} + +#[derive(Debug, FromArgs, PartialEq)] +#[argh(subcommand, name = "statusline")] +/// Manage the tmux statusline integration. +struct StatuslineGroupCommand { + #[argh(subcommand)] + command: StatuslineSubcommand, +} + +#[derive(Debug, FromArgs, PartialEq)] +#[argh(subcommand)] +enum StatuslineSubcommand { + Install(StatuslineInstallCommand), + Render(StatuslineRenderCommand), + Uninstall(StatuslineUninstallCommand), +} + +#[derive(Debug, FromArgs, PartialEq)] +#[argh(subcommand, name = "install")] +/// Install the Wisp tmux statusline. +struct StatuslineInstallCommand { + /// tmux status row to install into. + #[argh(option)] + line: Option, +} + +#[derive(Debug, FromArgs, PartialEq)] +#[argh(subcommand, name = "render")] +/// Render the Wisp tmux statusline. +struct StatuslineRenderCommand { + /// force passive rendering. + #[argh(switch)] + force_passive: bool, + + /// force clickable rendering. + #[argh(switch)] + force_clickable: bool, +} + +#[derive(Debug, FromArgs, PartialEq)] +#[argh(subcommand, name = "uninstall")] +/// Remove the Wisp tmux statusline. +struct StatuslineUninstallCommand { + /// tmux status row to remove from. + #[argh(option)] + line: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UiMode { + Picker, + SidebarCompact, + SidebarExpanded, +} + +impl FromArgValue for UiMode { + fn from_arg_value(value: &str) -> Result { + match value { + "picker" => Ok(Self::Picker), + "sidebar-compact" => Ok(Self::SidebarCompact), + "sidebar-expanded" => Ok(Self::SidebarExpanded), + other => Err(format!( + "expected one of \"picker\", \"sidebar-compact\", or \"sidebar-expanded\", got `{other}`" + )), + } + } +} + +impl UiMode { + fn surface_kind(self) -> SurfaceKind { + match self { + Self::Picker => SurfaceKind::Picker, + Self::SidebarCompact => SurfaceKind::SidebarCompact, + Self::SidebarExpanded => SurfaceKind::SidebarExpanded, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PreviewMode { Pane, @@ -92,13 +222,6 @@ struct SidebarRuntime { pane_id: Option, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum StatuslineCommand { - Install { line: Option }, - Render { force: Option }, - Uninstall { line: Option }, -} - fn main() -> ExitCode { match run() { Ok(()) => ExitCode::SUCCESS, @@ -110,44 +233,137 @@ fn main() -> ExitCode { } fn run() -> Result<(), Box> { - let args = env::args().collect::>(); - let command = args.get(1).cloned().unwrap_or_else(|| "popup".to_string()); - let config = load_config(&LoadOptions { - cli_overrides: CliOverrides::default(), - ..LoadOptions::default() - })?; - - match command.as_str() { - "print-config" => { - println!("{config:#?}"); - Ok(()) - } - "doctor" => { - doctor(); - Ok(()) + match parse_cli() { + Ok(cli) => execute_cli(cli), + Err(early_exit) => { + if early_exit.status.is_ok() { + print!("{}", early_exit.output); + Ok(()) + } else { + Err(early_exit.output.into()) + } } - "status-line" => run_statusline_command(&config, StatuslineCommand::Install { line: None }), - "statusline" => { - let status_command = parse_statusline_command(&args[2..])?; - run_statusline_command(&config, status_command) + } +} + +enum ParsedCli { + Public(Cli), + Ui(UiMode), +} + +fn ui_help_early_exit(command_name: &str) -> argh::EarlyExit { + argh::EarlyExit { + output: format!( + "Usage: {command_name} ui \n\n Internal helper for launching a specific surface.\n\n Modes:\n picker Open the picker surface.\n sidebar-compact Open the compact sidebar surface.\n sidebar-expanded Open the expanded sidebar surface.\n" + ), + status: Ok(()), + } +} + +fn parse_cli_args(args: &[String]) -> Result { + let command_name = command_name(&args[0]); + let mut cli_args = args.iter().skip(1).cloned().collect::>(); + + if matches!(cli_args.first().map(String::as_str), Some("ui")) { + match cli_args.get(1).map(String::as_str) { + None => return Err(ui_help_early_exit(&command_name)), + Some("-h") | Some("--help") | Some("help") => { + return Err(ui_help_early_exit(&command_name)); + } + Some(mode) => { + if cli_args.len() > 2 { + return Err(ui_parse_error( + "ui accepts exactly one surface mode: picker, sidebar-compact, or sidebar-expanded", + )); + } + + let mode = UiMode::from_arg_value(mode).map_err(ui_parse_error)?; + return Ok(ParsedCli::Ui(mode)); + } } - "fullscreen" => run_surface(SurfaceKind::Picker, &config), - "popup" => open_popup_or_run_inline(SurfaceKind::Picker, &config), - "sidebar-popup" => open_sidebar_popup_or_run_inline(&config), - "sidebar-pane" => open_sidebar_pane(), - "ui" => { - let inline = env::args().nth(2).unwrap_or_else(|| "picker".to_string()); - let kind = match inline.as_str() { - "sidebar-compact" => SurfaceKind::SidebarCompact, - "sidebar-expanded" => SurfaceKind::SidebarExpanded, - _ => SurfaceKind::Picker, - }; - run_surface(kind, &config) + } + + if cli_args.first().is_some_and(|arg| arg == "status-line") + && let Some(command) = cli_args.first_mut() + { + *command = "statusline".to_string(); + } + + if cli_args.is_empty() { + cli_args.push("help".to_string()); + } + + let command_name = [command_name.as_str()]; + let cli_args = cli_args.iter().map(String::as_str).collect::>(); + Cli::from_args(&command_name, &cli_args).map(ParsedCli::Public) +} + +fn parse_cli() -> Result { + let args = env::args().collect::>(); + parse_cli_args(&args) +} + +fn command_name(path: &str) -> String { + Path::new(path) + .file_name() + .and_then(|file_name| file_name.to_str()) + .unwrap_or("wisp") + .to_string() +} + +fn ui_parse_error(message: impl Into) -> argh::EarlyExit { + argh::EarlyExit { + output: format!("{}\n", message.into()), + status: Err(()), + } +} + +fn execute_cli(cli: ParsedCli) -> Result<(), Box> { + match cli { + ParsedCli::Ui(mode) => { + let config = load_runtime_config()?; + run_surface(mode.surface_kind(), &config) } - _ => run_surface(SurfaceKind::Picker, &config), + ParsedCli::Public(cli) => match cli.command { + Command::Doctor(_) => { + doctor(); + Ok(()) + } + Command::PrintConfig(_) => { + let config = load_runtime_config()?; + println!("{config:#?}"); + Ok(()) + } + Command::Fullscreen(_) => { + let config = load_runtime_config()?; + run_surface(SurfaceKind::Picker, &config) + } + Command::Popup(_) => { + let config = load_runtime_config()?; + open_popup_or_run_inline(SurfaceKind::Picker, &config) + } + Command::SidebarPopup(_) => { + let config = load_runtime_config()?; + open_sidebar_popup_or_run_inline(&config) + } + Command::SidebarPane(_) => open_sidebar_pane(), + Command::Statusline(statusline) => { + validate_statusline_flags(&statusline)?; + let config = load_runtime_config()?; + run_statusline_group(&config, statusline) + } + }, } } +fn load_runtime_config() -> Result> { + load_config(&LoadOptions { + cli_overrides: CliOverrides::default(), + ..LoadOptions::default() + }) + .map_err(|error| Box::new(error) as Box) +} + fn doctor() { let tmux = CommandTmuxClient::new(); let zoxide = CommandZoxideProvider::new(); @@ -177,66 +393,47 @@ fn doctor() { println!("event strategy: {:?}", backend.event_strategy()); } -fn parse_statusline_command(args: &[String]) -> Result> { - let mut positionals = Vec::new(); - let mut force = None; - let mut line = None; - let mut index = 0; - - while index < args.len() { - match args[index].as_str() { - "--force-passive" => force = Some(StatusRenderMode::Passive), - "--force-clickable" => force = Some(StatusRenderMode::Clickable), - "--line" => { - let raw = args - .get(index + 1) - .ok_or_else(|| "missing value for --line".to_string())?; - line = Some(raw.parse::()?); - index += 1; - } - value if value.starts_with("--") => { - return Err(format!("unsupported statusline flag `{value}`").into()); +fn validate_statusline_flags(statusline: &StatuslineGroupCommand) -> Result<(), Box> { + match &statusline.command { + StatuslineSubcommand::Install(args) => { + if args.line == Some(0) { + return Err("statusline line must be >= 1".into()); } - value => positionals.push(value.to_string()), } - index += 1; - } - - let subcommand = positionals.first().map(String::as_str).unwrap_or("install"); - match subcommand { - "install" => { - if force.is_some() { - Err("install does not accept render mode flags".into()) - } else { - Ok(StatuslineCommand::Install { line }) + StatuslineSubcommand::Uninstall(args) => { + if args.line == Some(0) { + return Err("statusline line must be >= 1".into()); } } - "render" => { - if line.is_some() { - Err("render does not accept --line".into()) - } else { - Ok(StatuslineCommand::Render { force }) - } - } - "uninstall" => { - if force.is_some() { - Err("uninstall does not accept render mode flags".into()) - } else { - Ok(StatuslineCommand::Uninstall { line }) + StatuslineSubcommand::Render(args) => { + if args.force_passive && args.force_clickable { + return Err( + "statusline render accepts only one of --force-passive or --force-clickable" + .into(), + ); } } - other => Err(format!("unsupported statusline subcommand `{other}`").into()), } + Ok(()) } -fn run_statusline_command( +fn run_statusline_group( config: &ResolvedConfig, - command: StatuslineCommand, + command: StatuslineGroupCommand, ) -> Result<(), Box> { - match command { - StatuslineCommand::Install { line } => install_statusline(config, line), - StatuslineCommand::Render { force } => render_statusline(config, force), - StatuslineCommand::Uninstall { line } => uninstall_statusline(config, line), + match command.command { + StatuslineSubcommand::Install(args) => install_statusline(config, args.line), + StatuslineSubcommand::Render(args) => { + let force = if args.force_passive { + Some(StatusRenderMode::Passive) + } else if args.force_clickable { + Some(StatusRenderMode::Clickable) + } else { + None + }; + render_statusline(config, force) + } + StatuslineSubcommand::Uninstall(args) => uninstall_statusline(config, args.line), } } @@ -1272,7 +1469,7 @@ fn update_branch_status( } fn branch_status_for_directory(path: &Path) -> Option<(GitBranchSync, bool)> { - let output = Command::new("git") + let output = ProcessCommand::new("git") .arg("-C") .arg(path) .args(["status", "--porcelain=2", "--branch"]) @@ -1395,14 +1592,15 @@ mod tests { use crate::{ SIDEBAR_PANE_TITLE, SIDEBAR_PANE_WIDTH, STATUSLINE_REFRESH_COMMAND, - STATUSLINE_REFRESH_HOOKS, SidebarRuntime, StatuslineCommand, SurfaceKind, - activate_filter_selection, apply_session_sort, clear_sidebar_ui_state, - create_session_from_query, current_session_id, disable_sidebar_for_session, filter_items, - install_statusline_refresh_hooks, load_sidebar_ui_state, parse_statusline_command, - persist_sidebar_ui_state, picker_bindings, reconcile_sidebar_for_current_context, - selected_index_for_session, sidebar_requires_handoff, sidebar_state_path, - sidebar_surface_command, statusline_command_expression, statusline_mode, - uninstall_statusline_refresh_hooks, + STATUSLINE_REFRESH_HOOKS, SidebarRuntime, StatuslineGroupCommand, StatuslineRenderCommand, + StatuslineSubcommand, SurfaceKind, activate_filter_selection, apply_session_sort, + clear_sidebar_ui_state, create_session_from_query, current_session_id, + disable_sidebar_for_session, filter_items, install_statusline_refresh_hooks, + load_sidebar_ui_state, persist_sidebar_ui_state, picker_bindings, + reconcile_sidebar_for_current_context, selected_index_for_session, + sidebar_requires_handoff, sidebar_state_path, sidebar_surface_command, + statusline_command_expression, statusline_mode, uninstall_statusline_refresh_hooks, + validate_statusline_flags, }; #[derive(Default)] @@ -1836,26 +2034,19 @@ mod tests { } #[test] - fn parses_statusline_subcommands_and_flags() { - assert_eq!( - parse_statusline_command(&[]).expect("default subcommand"), - StatuslineCommand::Install { line: None } - ); - assert_eq!( - parse_statusline_command(&["render".to_string(), "--force-clickable".to_string()]) - .expect("render flags"), - StatuslineCommand::Render { - force: Some(StatusRenderMode::Clickable), - } - ); - assert_eq!( - parse_statusline_command(&[ - "uninstall".to_string(), - "--line".to_string(), - "3".to_string() - ]) - .expect("uninstall line"), - StatuslineCommand::Uninstall { line: Some(3) } + fn statusline_render_rejects_conflicting_force_flags() { + let command = StatuslineGroupCommand { + command: StatuslineSubcommand::Render(StatuslineRenderCommand { + force_passive: true, + force_clickable: true, + }), + }; + let error = + validate_statusline_flags(&command).expect_err("conflicting flags should be rejected"); + assert!( + error + .to_string() + .contains("only one of --force-passive or --force-clickable") ); } diff --git a/crates/wisp-bin/tests/smoke.rs b/crates/wisp-bin/tests/smoke.rs index 7555589..3cbecfb 100644 --- a/crates/wisp-bin/tests/smoke.rs +++ b/crates/wisp-bin/tests/smoke.rs @@ -5,16 +5,31 @@ fn bin() -> &'static str { } #[test] -fn doctor_command_reports_runtime_environment() { +fn no_args_prints_top_level_help() { + let output = Command::new(bin()).output().expect("run wisp"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("Commands:")); + assert!(stdout.contains("popup")); + assert!(stdout.contains("statusline")); + assert!(!stdout.contains("ui")); +} + +#[test] +fn explicit_help_prints_top_level_help() { let output = Command::new(bin()) - .arg("doctor") + .arg("--help") .output() - .expect("run doctor"); + .expect("run help"); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("wisp doctor")); - assert!(stdout.contains("event strategy")); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("Commands:")); + assert!(stdout.contains("doctor")); + assert!(stdout.contains("statusline")); } #[test] @@ -30,6 +45,61 @@ fn print_config_command_dumps_effective_config() { assert!(stdout.contains("preview_width")); } +#[test] +fn statusline_help_lists_nested_subcommands() { + let output = Command::new(bin()) + .args(["statusline", "--help"]) + .output() + .expect("run statusline help"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("install")); + assert!(stdout.contains("render")); + assert!(stdout.contains("uninstall")); +} + +#[test] +fn status_line_help_lists_nested_subcommands() { + let output = Command::new(bin()) + .args(["status-line", "--help"]) + .output() + .expect("run status-line help"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Usage:")); + assert!(stdout.contains("install")); + assert!(stdout.contains("render")); + assert!(stdout.contains("uninstall")); +} + +#[test] +fn unknown_command_is_rejected() { + let output = Command::new(bin()) + .arg("does-not-exist") + .output() + .expect("run unknown command"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("does-not-exist")); +} + +#[test] +fn doctor_command_reports_runtime_environment() { + let output = Command::new(bin()) + .arg("doctor") + .output() + .expect("run doctor"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("wisp doctor")); + assert!(stdout.contains("event strategy")); +} + #[test] fn statusline_render_command_prints_status_output() { let output = Command::new(bin()) From 8b3a1812e5900afbdf10d386843af7c673a1b509 Mon Sep 17 00:00:00 2001 From: Emma <817422+Pajn@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:58:37 +0100 Subject: [PATCH 2/3] Set MSRV to one that actually work --- .github/workflows/ci.yml | 4 ++-- Cargo.toml | 2 +- rust-toolchain.toml | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 rust-toolchain.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36565d2..31d8190 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,9 +22,9 @@ jobs: uses: actions/checkout@v4 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@master + uses: dtolnay/rust-toolchain@stable with: - toolchain: 1.85.0 + toolchain: 1.90 components: clippy, rustfmt - name: Cache cargo artifacts diff --git a/Cargo.toml b/Cargo.toml index ce572c2..78e7f0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ resolver = "3" [workspace.package] version = "0.1.0" edition = "2024" -rust-version = "1.85" +rust-version = "1.90" license = "MIT" publish = false diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..292fe49 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" From a8fd8d89e9ca2b26aadae3abbca0679968b3a3ff Mon Sep 17 00:00:00 2001 From: Emma <817422+Pajn@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:13:52 +0100 Subject: [PATCH 3/3] Install tmux in CI --- .github/workflows/ci.yml | 5 +++++ crates/wisp-bin/tests/smoke.rs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31d8190..c188870 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,11 @@ jobs: toolchain: 1.90 components: clippy, rustfmt + - name: Install tmux + run: | + sudo apt-get update && sudo apt-get install -y tmux + tmux new-session -d + - name: Cache cargo artifacts uses: swatinem/rust-cache@v2 diff --git a/crates/wisp-bin/tests/smoke.rs b/crates/wisp-bin/tests/smoke.rs index 3cbecfb..882df09 100644 --- a/crates/wisp-bin/tests/smoke.rs +++ b/crates/wisp-bin/tests/smoke.rs @@ -107,6 +107,11 @@ fn statusline_render_command_prints_status_output() { .output() .expect("run statusline render"); + if !output.status.success() { + eprintln!("statusline render failed!"); + eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout)); + eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr)); + } assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("󰖔"));