use std::process::ExitCode;
use clap::{CommandFactory, Parser};
use summon::app;
use summon::config;
use summon::controller;
use summon::daemon;
use summon::diagnostics;
use summon::runner;
#[derive(Debug, Parser)]
#[command(name = "summon", version, about)]
pub struct Cli {
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
pub verbose: u8,
#[command(subcommand)]
pub command: Option<Command>,
#[arg(value_name = "BINDING")]
pub binding: Option<String>,
}
#[derive(Debug, clap::Subcommand)]
pub enum Command {
App {
app: String,
},
List,
Config {
#[command(subcommand)]
subcommand: ConfigCommand,
},
Doctor {
#[arg(long)]
request_accessibility: bool,
app: Option<String>,
},
Inspect {
#[command(subcommand)]
subcommand: InspectCommand,
},
Daemon {
#[command(subcommand)]
subcommand: DaemonCommand,
},
}
#[derive(Debug, Clone, Copy, clap::Subcommand)]
pub enum ConfigCommand {
Path,
Check,
}
#[derive(Debug, Clone, clap::Subcommand)]
pub enum InspectCommand {
Windows {
app: String,
#[arg(long)]
pretty: bool,
},
}
#[derive(Debug, Clone, Copy, clap::Subcommand)]
pub enum DaemonCommand {
Start,
Run,
Status,
Stop,
}
pub fn run(cli: Cli) -> ExitCode {
let verbose = cli.verbose;
match cli.command {
Some(Command::Config { subcommand }) => run_config(subcommand),
Some(Command::App { ref app }) => run_app(app, verbose),
Some(Command::List) => run_list(),
Some(Command::Doctor {
request_accessibility,
ref app,
}) => run_doctor(request_accessibility, app.as_deref()),
Some(Command::Inspect { subcommand }) => run_inspect(subcommand),
Some(Command::Daemon { subcommand }) => run_daemon(subcommand),
None => {
if let Some(binding) = cli.binding {
run_binding(&binding, verbose)
} else {
let _ = Cli::command().print_help();
ExitCode::SUCCESS
}
}
}
}
fn run_config(subcommand: ConfigCommand) -> ExitCode {
match subcommand {
ConfigCommand::Path => run_config_path(),
ConfigCommand::Check => run_config_check(),
}
}
fn run_list() -> ExitCode {
let path = match config::config_path() {
Ok(p) => p,
Err(err) => {
eprintln!("{err}");
return ExitCode::FAILURE;
}
};
let config = match config::load_from(&path) {
Ok(c) => c,
Err(err) => {
eprintln!("Config error in {}:", path.display());
eprintln!(" {err}");
return ExitCode::FAILURE;
}
};
if config.bindings.is_empty() {
println!("No bindings configured.");
return ExitCode::SUCCESS;
}
let max_name_len = config.bindings.keys().map(String::len).max().unwrap_or(0);
for (name, binding) in &config.bindings {
println!("{name:max_name_len$} -> {}", binding.app);
}
ExitCode::SUCCESS
}
fn run_config_path() -> ExitCode {
match config::config_path() {
Ok(path) => {
println!("{}", path.display());
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("{err}");
ExitCode::FAILURE
}
}
}
fn run_config_check() -> ExitCode {
let path = match config::config_path() {
Ok(p) => p,
Err(err) => {
eprintln!("{err}");
return ExitCode::FAILURE;
}
};
match config::load_from(&path) {
Ok(config) => {
let count = config.bindings.len();
println!("Config is valid: {}", path.display());
println!(" {count} binding(s) configured");
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("Config error in {}:", path.display());
eprintln!(" {err}");
ExitCode::FAILURE
}
}
}
fn run_app(app: &str, verbose: u8) -> ExitCode {
emit_run_output(daemon::run_app_or_direct(app, verbose))
}
fn run_binding(name: &str, verbose: u8) -> ExitCode {
let path = match config::config_path() {
Ok(p) => p,
Err(err) => {
eprintln!("{err}");
return ExitCode::FAILURE;
}
};
emit_run_output(daemon::run_binding_or_direct(name, &path, verbose))
}
fn run_doctor(request_accessibility: bool, app: Option<&str>) -> ExitCode {
println!("Summon doctor");
println!();
let target = match app {
Some(app) => match app::classify_app_target(app) {
Ok(target) => Some(target),
Err(err) => {
eprintln!("Invalid app target: {err}");
return ExitCode::FAILURE;
}
},
None => None,
};
let result = diagnostics::run_doctor(diagnostics::DoctorOptions {
request_accessibility,
target: target.as_ref(),
});
println!();
println!(
"{} check(s): {} passed, {} warning(s), {} failed",
result.checks, result.passed, result.warnings, result.failures
);
if result.is_ok() {
println!("Summon looks healthy.");
ExitCode::SUCCESS
} else {
eprintln!("Some checks failed. See above for details.");
ExitCode::FAILURE
}
}
fn run_daemon(subcommand: DaemonCommand) -> ExitCode {
match subcommand {
DaemonCommand::Start => match daemon::start() {
Ok(status) => {
println!(
"Summon daemon running (pid {}, socket {})",
status.pid,
status.socket_path.display()
);
if let Ok(path) = daemon::log_path() {
println!(" log: {}", path.display());
}
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("{err}");
ExitCode::FAILURE
}
},
DaemonCommand::Run => match daemon::run_server() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("{err}");
ExitCode::FAILURE
}
},
DaemonCommand::Status => match daemon::status() {
Ok(status) => {
println!("Summon daemon: running");
println!(" pid: {}", status.pid);
println!(" socket: {}", status.socket_path.display());
println!(" protocol: v{}", status.protocol_version);
if let Ok(path) = daemon::log_path() {
println!(" log: {}", path.display());
}
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("Summon daemon: not running");
eprintln!(" {err}");
ExitCode::FAILURE
}
},
DaemonCommand::Stop => match daemon::stop() {
Ok(()) => {
println!("Summon daemon stopped.");
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("{err}");
ExitCode::FAILURE
}
},
}
}
fn run_inspect(subcommand: InspectCommand) -> ExitCode {
match subcommand {
InspectCommand::Windows { app, pretty } => {
let target = match app::classify_app_target(&app) {
Ok(target) => target,
Err(err) => {
eprintln!("Invalid app target: {err}");
return ExitCode::FAILURE;
}
};
let snapshot = match controller::capture_window_cycle_snapshot(&target) {
Ok(snapshot) => snapshot,
Err(err) => {
eprintln!("Failed to inspect windows for {app}: {err}");
return ExitCode::FAILURE;
}
};
let render = if pretty {
serde_json::to_string_pretty(&snapshot)
} else {
serde_json::to_string(&snapshot)
};
match render {
Ok(json) => {
println!("{json}");
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("Failed to serialize window snapshot: {err}");
ExitCode::FAILURE
}
}
}
}
}
fn emit_run_output(output: runner::RunOutput) -> ExitCode {
output.emit();
if output.success {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn parse_binding_shorthand() {
let cli = Cli::try_parse_from(["summon", "terminal"]).expect("should parse");
assert_eq!(cli.binding.as_deref(), Some("terminal"));
assert!(cli.command.is_none());
}
#[test]
fn parse_app_subcommand() {
let cli =
Cli::try_parse_from(["summon", "app", "com.mitchellh.ghostty"]).expect("should parse");
match cli.command {
Some(Command::App { app }) => assert_eq!(app, "com.mitchellh.ghostty"),
other => panic!("expected App command, got {other:?}"),
}
}
#[test]
fn parse_list() {
let cli = Cli::try_parse_from(["summon", "list"]).expect("should parse");
assert!(matches!(cli.command, Some(Command::List)));
}
#[test]
fn parse_config_path() {
let cli = Cli::try_parse_from(["summon", "config", "path"]).expect("should parse");
match cli.command {
Some(Command::Config {
subcommand: ConfigCommand::Path,
}) => {}
other => panic!("expected Config Path, got {other:?}"),
}
}
#[test]
fn parse_config_check() {
let cli = Cli::try_parse_from(["summon", "config", "check"]).expect("should parse");
match cli.command {
Some(Command::Config {
subcommand: ConfigCommand::Check,
}) => {}
other => panic!("expected Config Check, got {other:?}"),
}
}
#[test]
fn parse_doctor() {
let cli = Cli::try_parse_from(["summon", "doctor"]).expect("should parse");
assert!(matches!(
cli.command,
Some(Command::Doctor {
request_accessibility: false,
app: None
})
));
}
#[test]
fn parse_doctor_with_target_and_request_accessibility() {
let cli =
Cli::try_parse_from(["summon", "doctor", "--request-accessibility", "dev.zed.Zed"])
.expect("should parse");
assert!(matches!(
cli.command,
Some(Command::Doctor {
request_accessibility: true,
app: Some(_)
})
));
}
#[test]
fn parse_inspect_windows() {
let cli = Cli::try_parse_from(["summon", "inspect", "windows", "dev.zed.Zed", "--pretty"])
.expect("should parse");
match cli.command {
Some(Command::Inspect {
subcommand: InspectCommand::Windows { app, pretty },
}) => {
assert_eq!(app, "dev.zed.Zed");
assert!(pretty);
}
other => panic!("expected Inspect Windows, got {other:?}"),
}
}
#[test]
fn parse_daemon_start() {
let cli = Cli::try_parse_from(["summon", "daemon", "start"]).expect("should parse");
assert!(matches!(
cli.command,
Some(Command::Daemon {
subcommand: DaemonCommand::Start
})
));
}
#[test]
fn parse_daemon_status() {
let cli = Cli::try_parse_from(["summon", "daemon", "status"]).expect("should parse");
assert!(matches!(
cli.command,
Some(Command::Daemon {
subcommand: DaemonCommand::Status
})
));
}
#[test]
fn parse_verbose_short() {
let cli = Cli::try_parse_from(["summon", "-v", "terminal"]).expect("should parse");
assert_eq!(cli.verbose, 1);
}
#[test]
fn parse_verbose_double() {
let cli = Cli::try_parse_from(["summon", "-vv", "terminal"]).expect("should parse");
assert_eq!(cli.verbose, 2);
}
#[test]
fn positional_arg_accepted_as_binding() {
let cli = Cli::try_parse_from(["summon", "explode"]).expect("should parse as binding");
assert_eq!(cli.binding.as_deref(), Some("explode"));
assert!(cli.command.is_none());
}
#[test]
fn no_args_parses_successfully_and_run_prints_help() {
let cli = Cli::try_parse_from(["summon"]).expect("should parse");
assert!(cli.binding.is_none());
assert!(cli.command.is_none());
}
#[test]
fn format_binding_list_aligns_names() {
let config = config::parse(
r#"
[bindings.browser]
app = "com.brave.Browser"
[bindings.terminal]
app = "com.mitchellh.ghostty"
[bindings.editor]
app = "dev.zed.Zed"
"#,
)
.expect("should parse");
let max_name_len = config.bindings.keys().map(String::len).max().unwrap_or(0);
assert_eq!(max_name_len, 8);
let lines: Vec<String> = config
.bindings
.iter()
.map(|(name, binding)| format!("{name:max_name_len$} -> {}", binding.app))
.collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "browser -> com.brave.Browser");
assert_eq!(lines[1], "editor -> dev.zed.Zed");
assert_eq!(lines[2], "terminal -> com.mitchellh.ghostty");
}
#[test]
fn format_binding_list_single_binding() {
let config = config::parse(
r#"
[bindings.finder]
app = "com.apple.finder"
"#,
)
.expect("should parse");
let max_name_len = config.bindings.keys().map(String::len).max().unwrap_or(0);
let lines: Vec<String> = config
.bindings
.iter()
.map(|(name, binding)| format!("{name:max_name_len$} -> {}", binding.app))
.collect();
assert_eq!(lines, ["finder -> com.apple.finder"]);
}
#[test]
fn format_binding_list_empty_config() {
let config = config::parse("").expect("should parse empty config");
assert!(config.bindings.is_empty());
}
}