summon-switcher 0.2.0

A tiny macOS command-line tool for opening, focusing, and cycling applications from declarative keybindings.
//! Shared hot-path execution for direct mode and daemon mode.

use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::app::{self, AppTarget};
use crate::config::{self, Config, EffectiveSettings};
use crate::controller::{self, AppAction, DecisionContext, MacAppController};

/// Rendered command output for a summon invocation.
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)]
pub struct RunOutput {
    /// Whether the command succeeded.
    pub success: bool,
    /// Whether retrying in direct mode may succeed when daemon mode could not.
    #[serde(default)]
    pub should_fallback_direct: bool,
    /// Content to write to stdout.
    pub stdout: String,
    /// Content to write to stderr.
    pub stderr: String,
}

impl RunOutput {
    /// Writes the output to the current process stdio streams.
    pub fn emit(&self) {
        if !self.stdout.is_empty() {
            print!("{}", self.stdout);
        }
        if !self.stderr.is_empty() {
            eprint!("{}", self.stderr);
        }
    }
}

/// Runs `summon app <app>`.
pub fn run_app(app: &str, verbose: u8) -> RunOutput {
    let target = match app::classify_app_target(app) {
        Ok(target) => target,
        Err(err) => {
            return failure(format!("Invalid app target: {err}\n"));
        }
    };

    let settings = config::EffectiveSettings {
        launch_if_not_running: true,
        ..config::EffectiveSettings::default()
    };

    run_target(app, &target, &settings, verbose)
}

/// Runs `summon <binding>` using an explicit config path.
pub fn run_binding_from_path(name: &str, path: &Path, verbose: u8) -> RunOutput {
    let config = match config::load_from(path) {
        Ok(config) => config,
        Err(err) => {
            return failure(format!("Config error in {}:\n  {err}\n", path.display()));
        }
    };

    run_binding_with_config(name, path, &config, verbose)
}

/// Runs `summon <binding>` using a preloaded config.
pub fn run_binding_with_config(name: &str, path: &Path, config: &Config, verbose: u8) -> RunOutput {
    let resolved = match config::resolve_binding(config, name, path) {
        Ok(resolved) => resolved,
        Err(err) => {
            return failure(format!("{err}\n"));
        }
    };

    run_target(
        &resolved.name,
        &resolved.target,
        &resolved.settings,
        verbose,
    )
}

fn run_target(
    label: &str,
    target: &AppTarget,
    settings: &EffectiveSettings,
    verbose: u8,
) -> RunOutput {
    controller::with_autorelease_pool(|| {
        let controller = MacAppController::new();
        let observation = match controller.observe_target(target) {
            Ok(observation) => observation,
            Err(err) => {
                // When Accessibility is unavailable in this process (the
                // launchd-spawned daemon commonly lacks trust), retry in direct
                // mode rather than reporting a hard failure. Direct mode
                // re-observes and acts with working Accessibility.
                let should_fallback_direct =
                    matches!(err, controller::ControllerError::PermissionDenied { .. });
                return RunOutput {
                    success: false,
                    should_fallback_direct,
                    stdout: String::new(),
                    stderr: format!("Failed to inspect {label}: {err}\n"),
                };
            }
        };

        let (action, context) = controller::decide_action_for_observation(observation, settings);
        let stderr = render_decision(verbose, label, target, action, context);

        let core = controller.execute_action_with_observation(target, action, observation);
        // `open_window_when_empty` only applies when the action brings the app
        // to the foreground (Focus), leaves it foregrounded (AlreadyFocused), or
        // cycles its windows (Cycle) — never to Launch or LaunchDisabled.
        let ensure = settings.open_window_when_empty
            && matches!(
                action,
                AppAction::Focus | AppAction::AlreadyFocused | AppAction::Cycle
            );

        match window_follow_up(&core, ensure) {
            WindowFollowUp::None => match core {
                Ok(()) => RunOutput {
                    success: true,
                    should_fallback_direct: false,
                    stdout: String::new(),
                    stderr,
                },
                Err(err) => RunOutput {
                    success: false,
                    should_fallback_direct: matches!(
                        err,
                        controller::ControllerError::PermissionDenied { .. }
                    ),
                    stdout: String::new(),
                    stderr: format!("{stderr}Failed to {action:?} {label}: {err}\n"),
                },
            },
            WindowFollowUp::EnsureWindow => {
                // The core action focused the app successfully. If it is now
                // windowless, open a new window. A failure here is additive —
                // the focus already succeeded — so it is surfaced as a warning
                // rather than turning the invocation into a failure.
                match controller.ensure_window(target, observation) {
                    Ok(()) => RunOutput {
                        success: true,
                        should_fallback_direct: false,
                        stdout: String::new(),
                        stderr,
                    },
                    Err(err) => {
                        let note = if verbose > 0 {
                            format!("Focused {label} but could not open a new window: {err}\n")
                        } else {
                            String::new()
                        };
                        RunOutput {
                            success: true,
                            should_fallback_direct: false,
                            stdout: String::new(),
                            stderr: format!("{stderr}{note}"),
                        }
                    }
                }
            }
            WindowFollowUp::OpenWindowInstead => {
                // The core action found no windows to act on (e.g. Cycle on a
                // windowless app). Open a new window in its place.
                match controller.open_new_window(target) {
                    Ok(()) => RunOutput {
                        success: true,
                        should_fallback_direct: false,
                        stdout: String::new(),
                        stderr,
                    },
                    Err(err) => RunOutput {
                        success: false,
                        should_fallback_direct: matches!(
                            err,
                            controller::ControllerError::PermissionDenied { .. }
                        ),
                        stdout: String::new(),
                        stderr: format!("{stderr}Failed to open a new window for {label}: {err}\n"),
                    },
                }
            }
        }
    })
}

/// Decides what to do about the `open_window_when_empty` setting once the core
/// action has run.
///
/// This is a pure function over the core result and the `ensure` flag (whether
/// the setting is on and the action activates the app), which makes the
/// branching testable without a running macOS GUI.
#[derive(Debug, PartialEq, Eq)]
enum WindowFollowUp {
    /// No window follow-up: the feature is off, the action does not activate the
    /// app, or the core action failed with an error that opening a window would
    /// not address. The caller should map `core` directly.
    None,
    /// The core action succeeded and the app is foregrounded; ensure it has a
    /// window (no-op if one already exists).
    EnsureWindow,
    /// The core action failed because the app had no windows; open a new window
    /// instead.
    OpenWindowInstead,
}

fn window_follow_up(
    core: &Result<(), controller::ControllerError>,
    ensure: bool,
) -> WindowFollowUp {
    if !ensure {
        return WindowFollowUp::None;
    }
    match core {
        Ok(()) => WindowFollowUp::EnsureWindow,
        Err(controller::ControllerError::NoWindows { .. }) => WindowFollowUp::OpenWindowInstead,
        _ => WindowFollowUp::None,
    }
}

fn failure(stderr: String) -> RunOutput {
    RunOutput {
        success: false,
        should_fallback_direct: false,
        stdout: String::new(),
        stderr,
    }
}

fn render_decision(
    verbose: u8,
    label: &str,
    target: &AppTarget,
    action: AppAction,
    context: DecisionContext,
) -> String {
    if verbose == 0 {
        return String::new();
    }

    let mut output = format!(
        "summon {label}: running={} frontmost={:?} launch={} cycle={} -> {action:?}\n",
        context.is_running,
        context.frontmost,
        context.launch_when_missing,
        context.cycle_when_focused
    );

    if verbose > 1 {
        output.push_str(&format!(
            "  target={}\n",
            controller::target_display(target)
        ));
    }

    output
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
mod tests {
    use super::*;

    fn finder_target() -> AppTarget {
        AppTarget::BundleId("com.apple.finder".into())
    }

    #[test]
    fn window_follow_up_disabled_is_none() {
        // Setting off: never follow up, regardless of the core result.
        assert_eq!(window_follow_up(&Ok(()), false), WindowFollowUp::None);
        assert_eq!(
            window_follow_up(
                &Err(controller::ControllerError::NoWindows {
                    target: "Finder".into()
                }),
                false
            ),
            WindowFollowUp::None
        );
    }

    #[test]
    fn window_follow_up_success_ensures_window() {
        // Core action succeeded and the app is foregrounded: ensure it has a window.
        assert_eq!(
            window_follow_up(&Ok(()), true),
            WindowFollowUp::EnsureWindow
        );
    }

    #[test]
    fn window_follow_up_no_windows_opens_one_instead() {
        // Cycle on a windowless app: open a new window rather than erroring.
        assert_eq!(
            window_follow_up(
                &Err(controller::ControllerError::NoWindows {
                    target: "Finder".into()
                }),
                true
            ),
            WindowFollowUp::OpenWindowInstead
        );
    }

    #[test]
    fn window_follow_up_other_errors_are_propagated() {
        // Non-window errors (and the "windows exist but none cyclable" case) are
        // not rescued by opening a window — they propagate as-is.
        let focus_failed = Err(controller::ControllerError::FocusFailed {
            target: "Finder".into(),
            reason: "boom".into(),
        });
        assert_eq!(window_follow_up(&focus_failed, true), WindowFollowUp::None);

        let no_cyclable = Err(controller::ControllerError::NoCyclableWindows {
            target: "Finder".into(),
            total_windows: 2,
            rejected_windows: 2,
        });
        assert_eq!(window_follow_up(&no_cyclable, true), WindowFollowUp::None);
    }

    #[test]
    fn window_follow_up_ensure_flag_drives_behavior() {
        // Same core result, different flag -> different follow-up.
        let ok = Ok(());
        assert_eq!(window_follow_up(&ok, false), WindowFollowUp::None);
        assert_eq!(window_follow_up(&ok, true), WindowFollowUp::EnsureWindow);
        let _ = finder_target(); // keep the helper exercised
    }
}