use std::os::windows::process::CommandExt;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
use clap::{Parser, Subcommand};
use windows::Win32::UI::WindowsAndMessaging::AllowSetForegroundWindow;
use flow_wm::autostart;
use flow_wm::common::Direction;
use flow_wm::config;
use flow_wm::ipc::message::SocketMessage;
use flow_wm::ipc::message::SocketResponse;
use flow_wm::ipc::message::WindowMode;
use flow_wm::ipc::transport;
const DAEMON_START_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Parser)]
#[command(name = "flow", version, about = "FlowWM CLI")]
#[command(propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Start {
#[arg(long)]
config: Option<String>,
#[arg(long, value_name = "PATH")]
log_file: Option<String>,
#[arg(long)]
ahk: bool,
},
Stop {
#[arg(long)]
ahk: bool,
},
Config {
#[command(subcommand)]
command: ConfigCommands,
},
Query {
#[command(subcommand)]
command: QueryCommands,
},
Dispatch {
#[command(subcommand)]
command: DispatchCommands,
},
EnableAutostart {
#[arg(long)]
ahk: bool,
},
DisableAutostart,
}
#[derive(Debug, Subcommand)]
enum ConfigCommands {
Init {
#[arg(long)]
ahk: bool,
},
Reload,
Edit,
Path,
Check,
}
#[derive(Debug, Subcommand)]
enum QueryCommands {
All,
}
#[derive(Debug, Subcommand)]
enum DispatchCommands {
Focus {
#[command(subcommand)]
direction: FocusDirection,
},
SwapColumn {
#[command(subcommand)]
direction: HorizontalDirection,
},
MoveWindow {
#[command(subcommand)]
direction: MoveDirection,
},
MergeColumn {
#[command(subcommand)]
direction: HorizontalDirection,
},
Promote {
#[command(subcommand)]
direction: HorizontalDirection,
},
ExpandColumn,
ShrinkColumn,
Center,
CloseWindow,
SetWindow {
#[command(subcommand)]
mode: WindowMode,
},
SwitchWorkspace {
workspace_id: u32,
},
SwapWorkspace {
workspace_id: u32,
},
#[command(name = "move-to-workspace")]
MoveWindowToWorkspace {
workspace_id: u32,
},
}
#[derive(Debug, Subcommand)]
enum FocusDirection {
Left,
Right,
Up,
Down,
}
#[derive(Debug, Subcommand)]
enum HorizontalDirection {
Left,
Right,
}
#[derive(Debug, Subcommand)]
enum MoveDirection {
Left,
Right,
Up,
Down,
}
fn main() {
let cli = Cli::parse();
let result = match cli.command {
Commands::Start {
config,
log_file,
ahk,
} => cmd_start(config, log_file, ahk),
Commands::Stop { ahk } => cmd_stop(ahk),
Commands::Config { command } => cmd_config(command),
Commands::Query { command } => cmd_query(command),
Commands::Dispatch { command } => cmd_dispatch(command),
Commands::EnableAutostart { ahk } => cmd_enable_autostart(ahk),
Commands::DisableAutostart => cmd_disable_autostart(),
};
if let Err(e) = result {
eprintln!("flow: {e}");
std::process::exit(1);
}
}
fn cmd_start(
config_override: Option<String>,
log_file_override: Option<String>,
ahk: bool,
) -> Result<(), String> {
if let Some(ref dir) = config_override {
unsafe { std::env::set_var(config::dirs::CONFIG_DIR_ENV, dir) };
}
if transport::is_daemon_running() {
return Err("daemon is already running".into());
}
let config_dir = config::dirs::resolve_config_dir(config_override.as_deref().map(Path::new));
preflight_config_check(&config_dir)?;
spawn_daemon(log_file_override.as_deref())?;
wait_for_daemon()?;
println!("flow: daemon started");
if ahk {
let pid = autostart::spawn_ahk_script()?;
println!("flow: launched flow.ahk (pid {pid})");
}
Ok(())
}
fn preflight_config_check(config_dir: &Path) -> Result<(), String> {
let app_path = config::dirs::user_app_config_path_in(config_dir);
if let Err(e) = config::load_app_config(&app_path) {
return Err(format!("flow: cannot start: {e}"));
}
let rules_path = config::dirs::user_rules_path_in(config_dir);
if let Err(e) = config::load_rules_config(&rules_path) {
eprintln!("flow: warning: {e}; using default window rules");
}
Ok(())
}
fn cmd_stop(ahk: bool) -> Result<(), String> {
send_command(SocketMessage::Stop, "daemon stopped")?;
if ahk {
let stopped = autostart::stop_ahk_script()?;
if stopped {
println!("flow: stopped flow.ahk");
} else {
println!("flow: no tracked flow.ahk to stop");
}
}
Ok(())
}
fn cmd_enable_autostart(ahk: bool) -> Result<(), String> {
let report = autostart::enable_autostart(ahk)?;
println!(
"flow: autostart {} at {}",
if ahk { "enabled (--ahk)" } else { "enabled" },
report.shortcut.display()
);
Ok(())
}
fn cmd_disable_autostart() -> Result<(), String> {
let report = autostart::disable_autostart()?;
println!("flow: autostart disabled ({})", report.shortcut.display());
Ok(())
}
fn cmd_config(command: ConfigCommands) -> Result<(), String> {
match command {
ConfigCommands::Init { ahk } => cmd_config_init(ahk),
ConfigCommands::Reload => cmd_config_reload(),
ConfigCommands::Edit => cmd_config_edit(),
ConfigCommands::Path => cmd_config_path(),
ConfigCommands::Check => cmd_config_check(),
}
}
fn cmd_config_init(ahk: bool) -> Result<(), String> {
let dir = config::dirs::config_dir();
config::init_config_dir(&dir)?;
println!("flow: config initialized at {}", dir.display());
if ahk {
let ahk_path = dir.join("flow.ahk");
match config::write_ahk_template(&dir)? {
true => println!("flow: wrote flow.ahk at {}", ahk_path.display()),
false => println!(
"flow: flow.ahk already exists at {} (left untouched)",
ahk_path.display()
),
}
}
Ok(())
}
fn cmd_config_reload() -> Result<(), String> {
send_command(SocketMessage::ReloadConfig, "configuration reloaded")
}
fn cmd_config_edit() -> Result<(), String> {
let dir = config::dirs::config_dir();
let editor = resolve_editor()?;
println!("flow: opening {} in {}", dir.display(), editor);
let status = Command::new(&editor)
.arg(&dir)
.status()
.map_err(|e| format!("failed to start editor '{}': {e}", editor))?;
if !status.success() {
return Err(format!(
"editor '{}' exited with status {}",
editor,
status
.code()
.map_or_else(|| "unknown".to_string(), |c| c.to_string())
));
}
Ok(())
}
fn cmd_config_path() -> Result<(), String> {
let dir = config::dirs::config_dir();
println!("{}", dir.display());
Ok(())
}
fn cmd_config_check() -> Result<(), String> {
let dir = config::dirs::config_dir();
config::check_config(&dir)?;
println!("flow: configuration is valid");
Ok(())
}
fn cmd_query(command: QueryCommands) -> Result<(), String> {
match command {
QueryCommands::All => cmd_query_all(),
}
}
fn cmd_query_all() -> Result<(), String> {
let response = transport::send_message(&SocketMessage::QueryWindowsAll)
.map_err(|e| format!("failed to send command: {e}"))?;
match response {
SocketResponse::Data { payload } => {
let formatted =
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string());
println!("{formatted}");
Ok(())
}
SocketResponse::Error { message } => Err(format!("daemon error: {message}")),
SocketResponse::Ok => {
println!("flow: ok");
Ok(())
}
}
}
fn cmd_dispatch(command: DispatchCommands) -> Result<(), String> {
match command {
DispatchCommands::Focus { direction } => cmd_dispatch_focus(direction),
DispatchCommands::SwapColumn { direction } => cmd_dispatch_swap_column(direction),
DispatchCommands::MoveWindow { direction } => cmd_dispatch_move_window(direction),
DispatchCommands::MergeColumn { direction } => cmd_dispatch_merge_column(direction),
DispatchCommands::Promote { direction } => cmd_dispatch_promote(direction),
DispatchCommands::ExpandColumn => {
send_command(SocketMessage::ExpandColumn, "column expanded")
}
DispatchCommands::ShrinkColumn => {
send_command(SocketMessage::ShrinkColumn, "column shrunk")
}
DispatchCommands::Center => send_command(SocketMessage::Center, "viewport centered"),
DispatchCommands::CloseWindow => send_command(SocketMessage::CloseWindow, "window closed"),
DispatchCommands::SetWindow { mode } => cmd_dispatch_set_window(mode),
DispatchCommands::SwitchWorkspace { workspace_id } => send_command(
SocketMessage::SwitchWorkspace { workspace_id },
"workspace switched",
),
DispatchCommands::SwapWorkspace { workspace_id } => send_command(
SocketMessage::SwapWorkspace { workspace_id },
"workspace swapped",
),
DispatchCommands::MoveWindowToWorkspace { workspace_id } => send_command(
SocketMessage::MoveWindowToWorkspace { workspace_id },
"window moved to workspace",
),
}
}
fn cmd_dispatch_focus(direction: FocusDirection) -> Result<(), String> {
let msg = match direction {
FocusDirection::Left => SocketMessage::FocusLeft,
FocusDirection::Right => SocketMessage::FocusRight,
FocusDirection::Up => SocketMessage::FocusUp,
FocusDirection::Down => SocketMessage::FocusDown,
};
send_command(msg, "focus changed")
}
fn cmd_dispatch_swap_column(direction: HorizontalDirection) -> Result<(), String> {
let msg = match direction {
HorizontalDirection::Left => SocketMessage::SwapColumn {
direction: Direction::Left,
},
HorizontalDirection::Right => SocketMessage::SwapColumn {
direction: Direction::Right,
},
};
send_command(msg, "column swapped")
}
fn cmd_dispatch_move_window(direction: MoveDirection) -> Result<(), String> {
let msg = match direction {
MoveDirection::Left => SocketMessage::MoveWindow {
direction: Direction::Left,
},
MoveDirection::Right => SocketMessage::MoveWindow {
direction: Direction::Right,
},
MoveDirection::Up => SocketMessage::MoveWindow {
direction: Direction::Up,
},
MoveDirection::Down => SocketMessage::MoveWindow {
direction: Direction::Down,
},
};
send_command(msg, "window moved")
}
fn cmd_dispatch_merge_column(direction: HorizontalDirection) -> Result<(), String> {
let msg = match direction {
HorizontalDirection::Left => SocketMessage::MergeColumn {
direction: Direction::Left,
},
HorizontalDirection::Right => SocketMessage::MergeColumn {
direction: Direction::Right,
},
};
send_command(msg, "column merged")
}
fn cmd_dispatch_promote(direction: HorizontalDirection) -> Result<(), String> {
let msg = match direction {
HorizontalDirection::Left => SocketMessage::Promote {
direction: Direction::Left,
},
HorizontalDirection::Right => SocketMessage::Promote {
direction: Direction::Right,
},
};
send_command(msg, "window promoted")
}
fn cmd_dispatch_set_window(mode: WindowMode) -> Result<(), String> {
let msg = SocketMessage::SetWindow { mode };
let label = match mode {
WindowMode::Float => "float",
WindowMode::Tile => "tile",
WindowMode::Cycle => "cycle",
};
send_command(msg, &format!("window set to {label}"))
}
fn grant_foreground_permission() {
let _ = unsafe { AllowSetForegroundWindow(u32::MAX) };
}
fn send_command(msg: SocketMessage, success_msg: &str) -> Result<(), String> {
grant_foreground_permission();
let response =
transport::send_message(&msg).map_err(|e| format!("failed to send command: {e}"))?;
match response {
SocketResponse::Ok => {
println!("flow: {success_msg}");
Ok(())
}
SocketResponse::Error { message } => Err(format!("daemon error: {message}")),
SocketResponse::Data { .. } => {
println!("flow: {success_msg}");
Ok(())
}
}
}
fn resolve_editor() -> Result<String, String> {
if let Ok(editor) = std::env::var("EDITOR")
&& !editor.is_empty()
{
return Ok(editor);
}
if let Ok(editor) = std::env::var("VISUAL")
&& !editor.is_empty()
{
return Ok(editor);
}
Ok("notepad.exe".to_string())
}
fn spawn_daemon(log_file_override: Option<&str>) -> Result<(), String> {
let exe = find_daemon_exe()?;
const DETACHED: u32 = 0x00000200 | 0x08000000;
let child = daemon_command(&exe, log_file_override)
.creation_flags(DETACHED)
.spawn()
.or_else(|_| daemon_command(&exe, log_file_override).spawn())
.map_err(|e| format!("failed to spawn daemon ({}): {e}", exe.display()))?;
drop(child);
Ok(())
}
fn daemon_command(exe: &std::path::Path, log_file_override: Option<&str>) -> Command {
let mut cmd = Command::new(exe);
if let Some(path) = log_file_override {
cmd.arg("--log-file").arg(path);
}
cmd
}
fn find_daemon_exe() -> Result<std::path::PathBuf, String> {
let current_exe =
std::env::current_exe().map_err(|e| format!("cannot determine current executable: {e}"))?;
let dir = current_exe
.parent()
.ok_or_else(|| "cannot determine executable directory".to_string())?;
let daemon = dir.join("flowd.exe");
if daemon.exists() {
return Ok(daemon);
}
Err(format!("daemon binary not found at {}", daemon.display()))
}
fn wait_for_daemon() -> Result<(), String> {
let deadline = std::time::Instant::now() + DAEMON_START_TIMEOUT;
loop {
if transport::is_daemon_running() {
return Ok(());
}
if std::time::Instant::now() >= deadline {
return Err("timed out waiting for daemon to start".into());
}
std::thread::sleep(Duration::from_millis(200));
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn grant_foreground_permission_does_not_panic() {
grant_foreground_permission();
}
#[test]
fn parse_start() {
let cli = Cli::try_parse_from(["flow", "start"]).unwrap();
assert!(matches!(
cli.command,
Commands::Start {
config: None,
log_file: None,
ahk: false
}
));
}
#[test]
fn parse_start_with_config_flag() {
let cli = Cli::try_parse_from(["flow", "start", "--config", "C:\\custom\\flow"]).unwrap();
match cli.command {
Commands::Start {
config: Some(ref c),
..
} => {
assert_eq!(c, "C:\\custom\\flow");
}
other => panic!("expected Start with --config, got: {other:?}"),
}
}
#[test]
fn parse_start_without_config_flag() {
let cli = Cli::try_parse_from(["flow", "start"]).unwrap();
match cli.command {
Commands::Start { config: None, .. } => {}
other => panic!("expected Start with no --config, got: {other:?}"),
}
}
#[test]
fn parse_start_with_log_file_flag() {
let cli =
Cli::try_parse_from(["flow", "start", "--log-file", "C:\\tmp\\debug.log"]).unwrap();
match cli.command {
Commands::Start {
log_file: Some(ref p),
..
} => {
assert_eq!(p, "C:\\tmp\\debug.log");
}
other => panic!("expected Start with --log-file, got: {other:?}"),
}
}
#[test]
fn parse_start_with_config_and_log_file_flags() {
let cli = Cli::try_parse_from([
"flow",
"start",
"--config",
"C:\\custom\\flow",
"--log-file",
"C:\\tmp\\debug.log",
])
.unwrap();
match cli.command {
Commands::Start {
config: Some(ref c),
log_file: Some(ref p),
ahk: false,
} => {
assert_eq!(c, "C:\\custom\\flow");
assert_eq!(p, "C:\\tmp\\debug.log");
}
other => panic!("expected Start with both flags, got: {other:?}"),
}
}
#[test]
fn parse_start_with_ahk_config_and_log_file_combined() {
let cli = Cli::try_parse_from([
"flow",
"start",
"--ahk",
"--config",
"C:\\custom\\flow",
"--log-file",
"C:\\tmp\\debug.log",
])
.unwrap();
match cli.command {
Commands::Start {
ahk: true,
config: Some(ref c),
log_file: Some(ref p),
} => {
assert_eq!(c, "C:\\custom\\flow");
assert_eq!(p, "C:\\tmp\\debug.log");
}
other => panic!("expected Start with all three flags, got: {other:?}"),
}
}
#[test]
fn parse_stop() {
let cli = Cli::try_parse_from(["flow", "stop"]).unwrap();
assert!(matches!(cli.command, Commands::Stop { ahk: false }));
}
#[test]
fn parse_start_with_ahk_flag() {
let cli = Cli::try_parse_from(["flow", "start", "--ahk"]).unwrap();
match cli.command {
Commands::Start {
ahk: true,
config: None,
log_file: None,
} => {}
other => panic!("expected Start {{ ahk: true, .. }}, got: {other:?}"),
}
}
#[test]
fn parse_stop_with_ahk_flag() {
let cli = Cli::try_parse_from(["flow", "stop", "--ahk"]).unwrap();
assert!(matches!(cli.command, Commands::Stop { ahk: true }));
}
#[test]
fn parse_start_positional_arg_fails() {
let result = Cli::try_parse_from(["flow", "start", "unexpected"]);
assert!(
result.is_err(),
"'flow start' with a positional arg should fail"
);
}
#[test]
fn parse_enable_autostart() {
let cli = Cli::try_parse_from(["flow", "enable-autostart"]).unwrap();
match cli.command {
Commands::EnableAutostart { ahk: false } => {}
other => panic!("expected EnableAutostart {{ ahk: false }}, got: {other:?}"),
}
}
#[test]
fn parse_enable_autostart_ahk() {
let cli = Cli::try_parse_from(["flow", "enable-autostart", "--ahk"]).unwrap();
match cli.command {
Commands::EnableAutostart { ahk: true } => {}
other => panic!("expected EnableAutostart {{ ahk: true }}, got: {other:?}"),
}
}
#[test]
fn parse_enable_autostart_extra_arg_fails() {
let result = Cli::try_parse_from(["flow", "enable-autostart", "bogus"]);
assert!(
result.is_err(),
"'flow enable-autostart' with a positional arg should fail"
);
}
#[test]
fn parse_enable_autostart_unknown_flag_fails() {
let result = Cli::try_parse_from(["flow", "enable-autostart", "--bogus"]);
assert!(result.is_err(), "unknown flag should fail");
}
#[test]
fn parse_disable_autostart() {
let cli = Cli::try_parse_from(["flow", "disable-autostart"]).unwrap();
assert!(matches!(cli.command, Commands::DisableAutostart));
}
#[test]
fn parse_disable_autostart_ahk_flag_fails() {
let result = Cli::try_parse_from(["flow", "disable-autostart", "--ahk"]);
assert!(
result.is_err(),
"'flow disable-autostart --ahk' should fail (no --ahk flag)"
);
}
#[test]
fn parse_disable_autostart_extra_arg_fails() {
let result = Cli::try_parse_from(["flow", "disable-autostart", "bogus"]);
assert!(
result.is_err(),
"'flow disable-autostart' with a positional arg should fail"
);
}
#[test]
fn parse_config_init() {
let cli = Cli::try_parse_from(["flow", "config", "init"]).unwrap();
match cli.command {
Commands::Config {
command: ConfigCommands::Init { ahk },
} => {
assert!(!ahk, "--ahk should default to false");
}
other => panic!("expected Config::Init, got: {other:?}"),
}
}
#[test]
fn parse_config_init_ahk() {
let cli = Cli::try_parse_from(["flow", "config", "init", "--ahk"]).unwrap();
match cli.command {
Commands::Config {
command: ConfigCommands::Init { ahk },
} => {
assert!(ahk, "--ahk should set ahk to true");
}
other => panic!("expected Config::Init {{ ahk: true }}, got: {other:?}"),
}
}
#[test]
fn parse_config_reload() {
let cli = Cli::try_parse_from(["flow", "config", "reload"]).unwrap();
match cli.command {
Commands::Config {
command: ConfigCommands::Reload,
} => {}
other => panic!("expected Config::Reload, got: {other:?}"),
}
}
#[test]
fn parse_config_edit() {
let cli = Cli::try_parse_from(["flow", "config", "edit"]).unwrap();
match cli.command {
Commands::Config {
command: ConfigCommands::Edit,
} => {}
other => panic!("expected Config::Edit, got: {other:?}"),
}
}
#[test]
fn parse_config_path() {
let cli = Cli::try_parse_from(["flow", "config", "path"]).unwrap();
match cli.command {
Commands::Config {
command: ConfigCommands::Path,
} => {}
other => panic!("expected Config::Path, got: {other:?}"),
}
}
#[test]
fn parse_config_check() {
let cli = Cli::try_parse_from(["flow", "config", "check"]).unwrap();
match cli.command {
Commands::Config {
command: ConfigCommands::Check,
} => {}
other => panic!("expected Config::Check, got: {other:?}"),
}
}
#[test]
fn parse_query_all() {
let cli = Cli::try_parse_from(["flow", "query", "all"]).unwrap();
match cli.command {
Commands::Query {
command: QueryCommands::All,
} => {}
other => panic!("expected Query::All, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_focus_left() {
let cli = Cli::try_parse_from(["flow", "dispatch", "focus", "left"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::Focus {
direction: FocusDirection::Left,
},
} => {}
other => panic!("expected Dispatch::Focus::Left, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_focus_right() {
let cli = Cli::try_parse_from(["flow", "dispatch", "focus", "right"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::Focus {
direction: FocusDirection::Right,
},
} => {}
other => panic!("expected Dispatch::Focus::Right, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_focus_up() {
let cli = Cli::try_parse_from(["flow", "dispatch", "focus", "up"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::Focus {
direction: FocusDirection::Up,
},
} => {}
other => panic!("expected Dispatch::Focus::Up, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_focus_down() {
let cli = Cli::try_parse_from(["flow", "dispatch", "focus", "down"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::Focus {
direction: FocusDirection::Down,
},
} => {}
other => panic!("expected Dispatch::Focus::Down, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_swap_column_left() {
let cli = Cli::try_parse_from(["flow", "dispatch", "swap-column", "left"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::SwapColumn {
direction: HorizontalDirection::Left,
},
} => {}
other => panic!("expected Dispatch::SwapColumn::Left, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_swap_column_right() {
let cli = Cli::try_parse_from(["flow", "dispatch", "swap-column", "right"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::SwapColumn {
direction: HorizontalDirection::Right,
},
} => {}
other => panic!("expected Dispatch::SwapColumn::Right, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_swap_column_without_direction_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "swap-column"]);
assert!(
result.is_err(),
"'flow dispatch swap-column' without a direction should fail"
);
}
#[test]
fn parse_dispatch_swap_column_vertical_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "swap-column", "up"]);
assert!(
result.is_err(),
"'swap-column up' should fail (only left/right)"
);
}
#[test]
fn parse_dispatch_move_window_left() {
let cli = Cli::try_parse_from(["flow", "dispatch", "move-window", "left"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::MoveWindow {
direction: MoveDirection::Left,
},
} => {}
other => panic!("expected Dispatch::MoveWindow::Left, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_move_window_right() {
let cli = Cli::try_parse_from(["flow", "dispatch", "move-window", "right"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::MoveWindow {
direction: MoveDirection::Right,
},
} => {}
other => panic!("expected Dispatch::MoveWindow::Right, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_move_window_up() {
let cli = Cli::try_parse_from(["flow", "dispatch", "move-window", "up"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::MoveWindow {
direction: MoveDirection::Up,
},
} => {}
other => panic!("expected Dispatch::MoveWindow::Up, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_move_window_down() {
let cli = Cli::try_parse_from(["flow", "dispatch", "move-window", "down"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::MoveWindow {
direction: MoveDirection::Down,
},
} => {}
other => panic!("expected Dispatch::MoveWindow::Down, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_move_window_without_direction_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "move-window"]);
assert!(
result.is_err(),
"'flow dispatch move-window' without a direction should fail"
);
}
#[test]
fn parse_dispatch_merge_column_left() {
let cli = Cli::try_parse_from(["flow", "dispatch", "merge-column", "left"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::MergeColumn {
direction: HorizontalDirection::Left,
},
} => {}
other => panic!("expected Dispatch::MergeColumn::Left, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_merge_column_right() {
let cli = Cli::try_parse_from(["flow", "dispatch", "merge-column", "right"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::MergeColumn {
direction: HorizontalDirection::Right,
},
} => {}
other => panic!("expected Dispatch::MergeColumn::Right, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_merge_column_vertical_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "merge-column", "up"]);
assert!(
result.is_err(),
"'merge-column up' should fail (only left/right)"
);
}
#[test]
fn parse_dispatch_promote_left() {
let cli = Cli::try_parse_from(["flow", "dispatch", "promote", "left"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::Promote {
direction: HorizontalDirection::Left,
},
} => {}
other => panic!("expected Dispatch::Promote::Left, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_promote_right() {
let cli = Cli::try_parse_from(["flow", "dispatch", "promote", "right"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::Promote {
direction: HorizontalDirection::Right,
},
} => {}
other => panic!("expected Dispatch::Promote::Right, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_promote_vertical_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "promote", "down"]);
assert!(
result.is_err(),
"'promote down' should fail (only left/right)"
);
}
#[test]
fn parse_dispatch_without_subcommand_fails() {
let result = Cli::try_parse_from(["flow", "dispatch"]);
assert!(
result.is_err(),
"'flow dispatch' without a subcommand should fail"
);
}
#[test]
fn parse_dispatch_focus_without_direction_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "focus"]);
assert!(
result.is_err(),
"'flow dispatch focus' without a direction should fail"
);
}
#[test]
fn parse_dispatch_invalid_subcommand_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "nonexistent"]);
assert!(result.is_err(), "unknown dispatch subcommand should fail");
}
#[test]
fn parse_dispatch_expand_column() {
let cli = Cli::try_parse_from(["flow", "dispatch", "expand-column"]).unwrap();
match cli.command {
Commands::Dispatch {
command: DispatchCommands::ExpandColumn,
} => {}
other => panic!("expected Dispatch::ExpandColumn, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_shrink_column() {
let cli = Cli::try_parse_from(["flow", "dispatch", "shrink-column"]).unwrap();
match cli.command {
Commands::Dispatch {
command: DispatchCommands::ShrinkColumn,
} => {}
other => panic!("expected Dispatch::ShrinkColumn, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_center() {
let cli = Cli::try_parse_from(["flow", "dispatch", "center"]).unwrap();
match cli.command {
Commands::Dispatch {
command: DispatchCommands::Center,
} => {}
other => panic!("expected Dispatch::Center, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_close_window() {
let cli = Cli::try_parse_from(["flow", "dispatch", "close-window"]).unwrap();
match cli.command {
Commands::Dispatch {
command: DispatchCommands::CloseWindow,
} => {}
other => panic!("expected Dispatch::CloseWindow, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_close_window_extra_arg_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "close-window", "extra"]);
assert!(
result.is_err(),
"'flow dispatch close-window' with an extra arg should fail"
);
}
#[test]
fn parse_dispatch_set_window_float() {
let cli = Cli::try_parse_from(["flow", "dispatch", "set-window", "float"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::SetWindow {
mode: WindowMode::Float,
},
} => {}
other => panic!("expected Dispatch::SetWindow::Float, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_set_window_tile() {
let cli = Cli::try_parse_from(["flow", "dispatch", "set-window", "tile"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::SetWindow {
mode: WindowMode::Tile,
},
} => {}
other => panic!("expected Dispatch::SetWindow::Tile, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_set_window_cycle() {
let cli = Cli::try_parse_from(["flow", "dispatch", "set-window", "cycle"]).unwrap();
match cli.command {
Commands::Dispatch {
command:
DispatchCommands::SetWindow {
mode: WindowMode::Cycle,
},
} => {}
other => panic!("expected Dispatch::SetWindow::Cycle, got: {other:?}"),
}
}
#[test]
fn parse_dispatch_set_window_without_mode_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "set-window"]);
assert!(
result.is_err(),
"'flow dispatch set-window' without a mode should fail"
);
}
#[test]
fn parse_dispatch_set_window_invalid_mode_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "set-window", "invalid"]);
assert!(
result.is_err(),
"'flow dispatch set-window invalid' should fail"
);
}
#[test]
fn parse_dispatch_expand_column_extra_arg_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "expand-column", "extra"]);
assert!(
result.is_err(),
"'flow dispatch expand-column' with extra args should fail"
);
}
#[test]
fn parse_dispatch_shrink_column_extra_arg_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "shrink-column", "extra"]);
assert!(
result.is_err(),
"'flow dispatch shrink-column' with extra args should fail"
);
}
#[test]
fn parse_dispatch_expand_column_multiple_extra_args_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "expand-column", "extra1", "extra2"]);
assert!(
result.is_err(),
"'flow dispatch expand-column' with multiple extra args should fail"
);
}
#[test]
fn parse_dispatch_shrink_column_multiple_extra_args_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "shrink-column", "extra1", "extra2"]);
assert!(
result.is_err(),
"'flow dispatch shrink-column' with multiple extra args should fail"
);
}
#[test]
fn parse_dispatch_switch_workspace() {
let cli = Cli::try_parse_from(["flow", "dispatch", "switch-workspace", "3"]).unwrap();
match cli.command {
Commands::Dispatch {
command: DispatchCommands::SwitchWorkspace { workspace_id: 3 },
} => {}
other => panic!("expected Dispatch::SwitchWorkspace(3), got: {other:?}"),
}
}
#[test]
fn parse_dispatch_switch_workspace_without_id_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "switch-workspace"]);
assert!(
result.is_err(),
"'flow dispatch switch-workspace' without an id should fail"
);
}
#[test]
fn parse_dispatch_switch_workspace_zero() {
let cli = Cli::try_parse_from(["flow", "dispatch", "switch-workspace", "0"]).unwrap();
match cli.command {
Commands::Dispatch {
command: DispatchCommands::SwitchWorkspace { workspace_id: 0 },
} => {}
other => panic!("expected Dispatch::SwitchWorkspace(0), got: {other:?}"),
}
}
#[test]
fn parse_dispatch_swap_workspace() {
let cli = Cli::try_parse_from(["flow", "dispatch", "swap-workspace", "7"]).unwrap();
match cli.command {
Commands::Dispatch {
command: DispatchCommands::SwapWorkspace { workspace_id: 7 },
} => {}
other => panic!("expected Dispatch::SwapWorkspace(7), got: {other:?}"),
}
}
#[test]
fn parse_dispatch_swap_workspace_without_id_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "swap-workspace"]);
assert!(
result.is_err(),
"'flow dispatch swap-workspace' without an id should fail"
);
}
#[test]
fn parse_dispatch_move_to_workspace() {
let cli = Cli::try_parse_from(["flow", "dispatch", "move-to-workspace", "11"]).unwrap();
match cli.command {
Commands::Dispatch {
command: DispatchCommands::MoveWindowToWorkspace { workspace_id: 11 },
} => {}
other => panic!("expected Dispatch::MoveWindowToWorkspace(11), got: {other:?}"),
}
}
#[test]
fn parse_dispatch_move_to_workspace_without_id_fails() {
let result = Cli::try_parse_from(["flow", "dispatch", "move-to-workspace"]);
assert!(
result.is_err(),
"'flow dispatch move-to-workspace' without an id should fail"
);
}
#[test]
fn parse_dispatch_workspace_command_rejects_non_numeric_id() {
let result = Cli::try_parse_from(["flow", "dispatch", "switch-workspace", "abc"]);
assert!(
result.is_err(),
"non-numeric workspace_id should be rejected"
);
}
#[test]
fn parse_no_subcommand_fails() {
let result = Cli::try_parse_from(["flow"]);
assert!(result.is_err(), "parsing with no subcommand should fail");
}
#[test]
fn parse_invalid_subcommand_fails() {
let result = Cli::try_parse_from(["flow", "nonexistent"]);
assert!(result.is_err(), "unknown subcommand should fail");
}
#[test]
fn parse_extra_arg_fails() {
let result = Cli::try_parse_from(["flow", "stop", "unexpected"]);
assert!(result.is_err(), "extra argument should fail");
}
#[test]
fn parse_empty_args_fails() {
let result = Cli::try_parse_from([""]);
assert!(result.is_err(), "empty args should fail");
}
#[test]
fn parse_old_reload_config_fails() {
let result = Cli::try_parse_from(["flow", "reload-config"]);
assert!(
result.is_err(),
"old 'reload-config' command should no longer parse"
);
}
#[test]
fn parse_old_check_config_fails() {
let result = Cli::try_parse_from(["flow", "check-config"]);
assert!(
result.is_err(),
"old 'check-config' command should no longer parse"
);
}
#[test]
fn parse_old_squished_dispatch_subcommands_fail() {
let old_forms = [
"swapcolumn",
"movewindow",
"expandcolumn",
"shrinkcolumn",
"closewindow",
"setwindow",
"switchworkspace",
"swapworkspace",
"movetoworkspace",
];
for old in old_forms {
let result = Cli::try_parse_from(["flow", "dispatch", old]);
assert!(
result.is_err(),
"old squished dispatch subcommand '{old}' should no longer parse"
);
}
}
#[test]
fn parse_config_invalid_subcommand_fails() {
let result = Cli::try_parse_from(["flow", "config", "nonexistent"]);
assert!(result.is_err(), "unknown config subcommand should fail");
}
#[test]
fn parse_config_subcommand_extra_arg_fails() {
let result = Cli::try_parse_from(["flow", "config", "path", "unexpected"]);
assert!(
result.is_err(),
"extra argument to config subcommand should fail"
);
}
#[test]
fn daemon_start_timeout_is_reasonable() {
assert!(
DAEMON_START_TIMEOUT.as_secs() > 0,
"DAEMON_START_TIMEOUT must be > 0"
);
assert!(
DAEMON_START_TIMEOUT.as_secs() <= 30,
"DAEMON_START_TIMEOUT must be <= 30s (user shouldn't wait longer)"
);
}
#[test]
fn daemon_start_timeout_is_not_zero() {
assert_ne!(
DAEMON_START_TIMEOUT,
Duration::ZERO,
"zero timeout would skip waiting entirely"
);
}
#[test]
fn wait_for_daemon_maps_not_found_error() {
let _: fn() -> Result<(), String> = wait_for_daemon;
}
#[test]
fn resolve_editor_returns_string() {
let result = resolve_editor();
assert!(result.is_ok(), "resolve_editor should always return Ok");
let editor = result.unwrap();
assert!(!editor.is_empty(), "editor command should not be empty");
}
}