use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
use crate::app::{self, AppTarget};
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum FocusStrategy {
#[default]
RecentWindow,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
#[serde(default)]
pub cycle_when_focused: bool,
#[serde(default)]
pub launch_if_not_running: bool,
#[serde(default)]
pub focus_strategy: FocusStrategy,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Binding {
pub app: String,
#[serde(default)]
pub cycle_when_focused: Option<bool>,
#[serde(default)]
pub launch_if_not_running: Option<bool>,
#[serde(default)]
pub focus_strategy: Option<FocusStrategy>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub settings: Settings,
#[serde(default)]
pub bindings: BTreeMap<String, Binding>,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("HOME environment variable is not set")]
NoHome,
#[error("Could not read config file: {path}\n {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("Invalid config:\n {0}")]
Parse(#[from] toml::de::Error),
#[error("Invalid config:\n {0}")]
Validation(String),
}
fn resolve_config_dir(
xdg_config_home: Option<&str>,
home: Option<&str>,
) -> Result<PathBuf, ConfigError> {
match xdg_config_home {
Some(dir) if !dir.is_empty() => Ok(PathBuf::from(dir)),
_ => {
let h = home.ok_or(ConfigError::NoHome)?;
Ok(PathBuf::from(h).join(".config"))
}
}
}
pub fn config_dir() -> Result<PathBuf, ConfigError> {
let xdg = std::env::var("XDG_CONFIG_HOME").ok();
let home = std::env::var("HOME").ok();
resolve_config_dir(xdg.as_deref(), home.as_deref())
}
pub fn config_path() -> Result<PathBuf, ConfigError> {
config_dir().map(|dir| dir.join("summon").join("summon.toml"))
}
pub fn parse(toml: &str) -> Result<Config, ConfigError> {
let config: Config = toml::from_str(toml)?;
validate(&config)?;
Ok(config)
}
pub fn load() -> Result<Config, ConfigError> {
let path = config_path()?;
load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Config, ConfigError> {
let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::Read {
path: path.to_path_buf(),
source: e,
})?;
parse(&contents)
}
fn validate(config: &Config) -> Result<(), ConfigError> {
for (name, binding) in &config.bindings {
if binding.app.trim().is_empty() {
return Err(ConfigError::Validation(format!(
"binding \"{name}\" has an empty app field"
)));
}
}
Ok(())
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct EffectiveSettings {
pub cycle_when_focused: bool,
pub launch_if_not_running: bool,
pub focus_strategy: FocusStrategy,
}
impl EffectiveSettings {
pub fn resolve(global: &Settings, binding: &Binding) -> Self {
Self {
cycle_when_focused: binding
.cycle_when_focused
.unwrap_or(global.cycle_when_focused),
launch_if_not_running: binding
.launch_if_not_running
.unwrap_or(global.launch_if_not_running),
focus_strategy: binding.focus_strategy.unwrap_or(global.focus_strategy),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolvedBinding {
pub name: String,
pub target: AppTarget,
pub settings: EffectiveSettings,
}
#[derive(Debug, Error)]
pub enum ResolveError {
#[error(
"Could not resolve binding: {name}\n\
No binding named \"{name}\" was found in:\n\
{path}\n\
Available bindings:\n\
{available}"
)]
BindingNotFound {
name: String,
path: PathBuf,
available: String,
},
#[error("Invalid app target for binding \"{binding}\": {source}")]
InvalidAppTarget {
binding: String,
#[source]
source: app::AppTargetError,
},
}
pub fn resolve_binding(
config: &Config,
name: &str,
config_path: &Path,
) -> Result<ResolvedBinding, ResolveError> {
let binding = config.bindings.get(name).ok_or_else(|| {
let available = format_available_bindings(&config.bindings);
ResolveError::BindingNotFound {
name: name.to_string(),
path: config_path.to_path_buf(),
available,
}
})?;
let target =
app::classify_app_target(&binding.app).map_err(|e| ResolveError::InvalidAppTarget {
binding: name.to_string(),
source: e,
})?;
let settings = EffectiveSettings::resolve(&config.settings, binding);
Ok(ResolvedBinding {
name: name.to_string(),
target,
settings,
})
}
fn format_available_bindings(bindings: &BTreeMap<String, Binding>) -> String {
if bindings.is_empty() {
" (none)".to_string()
} else {
bindings
.keys()
.map(|k| format!(" {k}"))
.collect::<Vec<_>>()
.join("\n")
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn resolve_config_dir_uses_xdg_when_set() {
let dir = resolve_config_dir(Some("/custom/config"), Some("/Users/test"))
.expect("should resolve");
assert_eq!(dir, PathBuf::from("/custom/config"));
}
#[test]
fn resolve_config_dir_ignores_empty_xdg() {
let dir = resolve_config_dir(Some(""), Some("/Users/test")).expect("should resolve");
assert_eq!(dir, PathBuf::from("/Users/test/.config"));
}
#[test]
fn resolve_config_dir_falls_back_to_home() {
let dir = resolve_config_dir(None, Some("/Users/test")).expect("should resolve");
assert_eq!(dir, PathBuf::from("/Users/test/.config"));
}
#[test]
fn resolve_config_dir_errors_when_no_home() {
let result = resolve_config_dir(None, None);
assert!(result.is_err(), "should error without HOME");
let err = result.unwrap_err();
assert!(
matches!(err, ConfigError::NoHome),
"expected NoHome error, got {err:?}"
);
}
#[test]
fn config_path_appends_summon_toml() {
let path = resolve_config_dir(Some("/xdg"), Some("/home"))
.map(|d| d.join("summon").join("summon.toml"))
.expect("should resolve");
assert_eq!(path, PathBuf::from("/xdg/summon/summon.toml"));
}
#[test]
fn parse_empty_toml() {
let config = parse("").expect("empty TOML should parse");
assert!(config.bindings.is_empty());
assert_eq!(config.settings, Settings::default());
}
#[test]
fn parse_bindings_only() {
let config = parse(
r#"
[bindings.terminal]
app = "com.mitchellh.ghostty"
[bindings.browser]
app = "com.brave.Browser"
"#,
)
.expect("should parse");
assert_eq!(config.bindings.len(), 2);
assert_eq!(config.bindings["terminal"].app, "com.mitchellh.ghostty");
assert_eq!(config.bindings["browser"].app, "com.brave.Browser");
}
#[test]
fn parse_full_config() {
let config = parse(
r#"
[settings]
cycle_when_focused = true
launch_if_not_running = true
focus_strategy = "recent-window"
[bindings.terminal]
app = "com.mitchellh.ghostty"
cycle_when_focused = false
[bindings.browser]
app = "com.brave.Browser"
"#,
)
.expect("should parse");
assert!(config.settings.cycle_when_focused);
assert!(config.settings.launch_if_not_running);
assert_eq!(config.settings.focus_strategy, FocusStrategy::RecentWindow);
assert_eq!(config.bindings["terminal"].cycle_when_focused, Some(false));
assert_eq!(config.bindings["browser"].cycle_when_focused, None);
}
#[test]
fn per_binding_overrides() {
let config = parse(
r#"
[settings]
cycle_when_focused = true
launch_if_not_running = false
[bindings.editor]
app = "dev.zed.Zed"
cycle_when_focused = false
launch_if_not_running = true
focus_strategy = "recent-window"
"#,
)
.expect("should parse");
let binding = &config.bindings["editor"];
assert_eq!(binding.cycle_when_focused, Some(false));
assert_eq!(binding.launch_if_not_running, Some(true));
assert_eq!(binding.focus_strategy, Some(FocusStrategy::RecentWindow));
}
#[test]
fn reject_unknown_settings_field() {
let result = parse(
r"
[settings]
unknown_field = true
",
);
assert!(result.is_err(), "should reject unknown settings field");
}
#[test]
fn reject_unknown_binding_field() {
let result = parse(
r#"
[bindings.test]
app = "com.example.app"
made_up = "value"
"#,
);
assert!(result.is_err(), "should reject unknown binding field");
}
#[test]
fn reject_unknown_top_level_field() {
let result = parse(
r"
[mystery]
x = 1
",
);
assert!(result.is_err(), "should reject unknown top-level field");
}
#[test]
fn reject_invalid_focus_strategy() {
let result = parse(
r#"
[settings]
focus_strategy = "nonexistent-strategy"
"#,
);
assert!(result.is_err(), "should reject invalid focus strategy");
}
#[test]
fn reject_missing_app_field() {
let result = parse(
r"
[bindings.broken]
cycle_when_focused = true
",
);
assert!(result.is_err(), "should reject binding without app");
}
#[test]
fn reject_empty_app_string() {
let result = parse(
r#"
[bindings.empty]
app = ""
"#,
);
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("empty app field"),
"error should mention empty app: {msg}"
);
}
#[test]
fn reject_whitespace_only_app() {
let result = parse(
r#"
[bindings.spaces]
app = " "
"#,
);
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("empty app field"),
"error should mention empty app: {msg}"
);
}
#[test]
fn load_from_file_success() {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time should move forward")
.as_nanos();
let dir = std::env::temp_dir().join(format!("summon_test_load_from_file_{suffix}"));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("summon.toml");
std::fs::write(
&path,
r#"
[bindings.finder]
app = "com.apple.finder"
"#,
)
.unwrap();
let config = load_from(&path).expect("should load from file");
assert_eq!(config.bindings["finder"].app, "com.apple.finder");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn load_from_missing_file() {
let path = std::env::temp_dir().join("summon_nonexistent_summon.toml");
let result = load_from(&path);
assert!(result.is_err());
let err = format!("{}", result.unwrap_err());
assert!(
err.contains("Could not read config file"),
"error should mention file read failure: {err}"
);
}
#[test]
fn settings_defaults() {
let settings = Settings::default();
assert!(!settings.cycle_when_focused);
assert!(!settings.launch_if_not_running);
assert_eq!(settings.focus_strategy, FocusStrategy::RecentWindow);
}
#[test]
fn binding_option_defaults_are_none() {
let binding: Binding =
toml::from_str(r#"app = "com.example.app""#).expect("should parse minimal binding");
assert_eq!(binding.app, "com.example.app");
assert_eq!(binding.cycle_when_focused, None);
assert_eq!(binding.launch_if_not_running, None);
assert_eq!(binding.focus_strategy, None);
}
#[test]
fn config_equality() {
let config = parse(
r#"
[bindings.terminal]
app = "com.mitchellh.ghostty"
"#,
)
.expect("should parse");
let expected = Config {
settings: Settings::default(),
bindings: {
let mut map = BTreeMap::new();
map.insert(
"terminal".into(),
Binding {
app: "com.mitchellh.ghostty".into(),
cycle_when_focused: None,
launch_if_not_running: None,
focus_strategy: None,
},
);
map
},
};
assert_eq!(config, expected);
}
#[test]
fn bindings_are_sorted_by_name() {
let config = parse(
r#"
[bindings.zebra]
app = "com.zebra"
[bindings.alpha]
app = "com.alpha"
[bindings.middle]
app = "com.middle"
"#,
)
.expect("should parse");
let names: Vec<&str> = config.bindings.keys().map(String::as_str).collect();
assert_eq!(names, ["alpha", "middle", "zebra"]);
}
#[test]
fn effective_settings_all_defaults() {
let global = Settings::default();
let binding = Binding {
app: "com.example.app".into(),
cycle_when_focused: None,
launch_if_not_running: None,
focus_strategy: None,
};
let effective = EffectiveSettings::resolve(&global, &binding);
assert!(!effective.cycle_when_focused);
assert!(!effective.launch_if_not_running);
assert_eq!(effective.focus_strategy, FocusStrategy::RecentWindow);
}
#[test]
fn effective_settings_global_values_used() {
let global = Settings {
cycle_when_focused: true,
launch_if_not_running: true,
focus_strategy: FocusStrategy::RecentWindow,
};
let binding = Binding {
app: "com.example.app".into(),
cycle_when_focused: None,
launch_if_not_running: None,
focus_strategy: None,
};
let effective = EffectiveSettings::resolve(&global, &binding);
assert!(effective.cycle_when_focused);
assert!(effective.launch_if_not_running);
}
#[test]
fn effective_settings_per_binding_overrides_global() {
let global = Settings {
cycle_when_focused: true,
launch_if_not_running: true,
focus_strategy: FocusStrategy::RecentWindow,
};
let binding = Binding {
app: "com.example.app".into(),
cycle_when_focused: Some(false),
launch_if_not_running: Some(false),
focus_strategy: None,
};
let effective = EffectiveSettings::resolve(&global, &binding);
assert!(!effective.cycle_when_focused);
assert!(!effective.launch_if_not_running);
assert_eq!(effective.focus_strategy, FocusStrategy::RecentWindow);
}
#[test]
fn effective_settings_partial_overrides() {
let global = Settings {
cycle_when_focused: true,
launch_if_not_running: false,
focus_strategy: FocusStrategy::RecentWindow,
};
let binding = Binding {
app: "com.example.app".into(),
cycle_when_focused: None,
launch_if_not_running: Some(true),
focus_strategy: None,
};
let effective = EffectiveSettings::resolve(&global, &binding);
assert!(effective.cycle_when_focused); assert!(effective.launch_if_not_running); }
#[test]
fn resolve_binding_found() {
let config = parse(
r#"
[settings]
cycle_when_focused = true
[bindings.terminal]
app = "com.mitchellh.ghostty"
"#,
)
.expect("should parse");
let path = PathBuf::from("/test/summon.toml");
let resolved = resolve_binding(&config, "terminal", &path).expect("should resolve");
assert_eq!(resolved.name, "terminal");
assert_eq!(
resolved.target,
AppTarget::BundleId("com.mitchellh.ghostty".into())
);
assert!(resolved.settings.cycle_when_focused);
}
#[test]
fn resolve_binding_not_found() {
let config = parse(
r#"
[bindings.browser]
app = "com.brave.Browser"
[bindings.editor]
app = "dev.zed.Zed"
"#,
)
.expect("should parse");
let path = PathBuf::from("/test/summon.toml");
let result = resolve_binding(&config, "terminal", &path);
assert!(result.is_err());
let err = result.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Could not resolve binding: terminal"),
"error should mention binding name: {msg}"
);
assert!(
msg.contains("/test/summon.toml"),
"error should mention config path: {msg}"
);
assert!(
msg.contains("browser") && msg.contains("editor"),
"error should list available bindings: {msg}"
);
}
#[test]
fn resolve_binding_not_found_empty_config() {
let config = parse("").expect("should parse empty config");
let path = PathBuf::from("/test/summon.toml");
let result = resolve_binding(&config, "anything", &path);
assert!(result.is_err());
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("(none)"),
"error should indicate no bindings available: {msg}"
);
}
#[test]
fn resolve_binding_inherits_per_binding_override() {
let config = parse(
r#"
[settings]
cycle_when_focused = true
launch_if_not_running = false
[bindings.editor]
app = "dev.zed.Zed"
launch_if_not_running = true
"#,
)
.expect("should parse");
let path = PathBuf::from("/test/summon.toml");
let resolved = resolve_binding(&config, "editor", &path).expect("should resolve");
assert!(resolved.settings.cycle_when_focused); assert!(resolved.settings.launch_if_not_running); }
#[test]
fn resolve_binding_classifies_bundle_id() {
let config = parse(
r#"
[bindings.finder]
app = "com.apple.finder"
"#,
)
.expect("should parse");
let resolved =
resolve_binding(&config, "finder", Path::new("/test.toml")).expect("should resolve");
assert_eq!(
resolved.target,
AppTarget::BundleId("com.apple.finder".into())
);
}
#[test]
fn resolve_binding_classifies_app_name() {
let config = parse(
r#"
[bindings.preview]
app = "Preview"
"#,
)
.expect("should parse");
let resolved =
resolve_binding(&config, "preview", Path::new("/test.toml")).expect("should resolve");
assert_eq!(resolved.target, AppTarget::AppName("Preview".into()));
}
#[test]
fn resolve_binding_classifies_app_path() {
let config = parse(
r#"
[bindings.custom]
app = "/Applications/My App.app"
"#,
)
.expect("should parse");
let resolved =
resolve_binding(&config, "custom", Path::new("/test.toml")).expect("should resolve");
assert_eq!(
resolved.target,
AppTarget::AppPath("/Applications/My App.app".into())
);
}
#[test]
fn resolve_binding_rejects_invalid_path() {
let config = parse(
r#"
[bindings.bad]
app = "/Applications/notanapp"
"#,
)
.expect("should parse");
let result = resolve_binding(&config, "bad", Path::new("/test.toml"));
assert!(result.is_err());
let err = result.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("bad"),
"error should mention binding name: {msg}"
);
assert!(
msg.contains(".app"),
"error should mention .app extension: {msg}"
);
}
#[test]
fn format_available_multiple_bindings() {
let config = parse(
r#"
[bindings.browser]
app = "com.brave.Browser"
[bindings.terminal]
app = "com.mitchellh.ghostty"
"#,
)
.expect("should parse");
let output = format_available_bindings(&config.bindings);
assert!(output.contains("browser"));
assert!(output.contains("terminal"));
let browser_pos = output.find("browser").expect("should find browser");
let terminal_pos = output.find("terminal").expect("should find terminal");
assert!(
browser_pos < terminal_pos,
"browser should appear before terminal"
);
}
#[test]
fn format_available_empty_bindings() {
let bindings = BTreeMap::new();
let output = format_available_bindings(&bindings);
assert_eq!(output, " (none)");
}
}