use std::env;
use std::path::{Path, PathBuf};
use crate::Result;
const VAR_PREFERENCES: &str = "alfred_preferences";
const VAR_PREFERENCES_LOCALHASH: &str = "alfred_preferences_localhash";
const VAR_THEME: &str = "alfred_theme";
const VAR_THEME_BACKGROUND: &str = "alfred_theme_background";
const VAR_THEME_SELECTION_BACKGROUND: &str = "alfred_theme_selection_background";
const VAR_THEME_SUBTEXT: &str = "alfred_theme_subtext";
const VAR_VERSION: &str = "alfred_version";
const VAR_VERSION_BUILD: &str = "alfred_version_build";
const VAR_WORKFLOW_BUNDLEID: &str = "alfred_workflow_bundleid";
const VAR_WORKFLOW_CACHE: &str = "alfred_workflow_cache";
const VAR_WORKFLOW_DATA: &str = "alfred_workflow_data";
const VAR_WORKFLOW_NAME: &str = "alfred_workflow_name";
const VAR_WORKFLOW_DESCRIPTION: &str = "alfred_workflow_description";
const VAR_WORKFLOW_UID: &str = "alfred_workflow_uid";
const VAR_WORKFLOW_VERSION: &str = "alfred_workflow_version";
const VAR_WORKFLOW_KEYWORD: &str = "alfred_workflow_keyword";
const VAR_DEBUG: &str = "alfred_debug";
pub(crate) const DEFAULT_ALFRED_VERSION: &str = "5.5";
pub(crate) const DEFAULT_ALFRED_VERSION_BUILD: &str = "2300";
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct WorkflowConfig {
pub workflow_bundleid: String,
pub workflow_cache: PathBuf,
pub workflow_data: PathBuf,
pub version: String,
pub version_build: String,
pub workflow_name: String,
pub workflow_version: Option<String>,
pub preferences: Option<String>,
pub preferences_localhash: Option<String>,
pub theme: Option<String>,
pub theme_background: Option<String>,
pub theme_selection_background: Option<String>,
pub theme_subtext: Option<String>,
pub workflow_description: Option<String>,
pub workflow_uid: Option<String>,
pub workflow_keyword: Option<String>,
pub debug: bool,
}
pub trait ConfigProvider {
fn config(&self) -> Result<WorkflowConfig>;
}
pub struct AlfredEnvProvider;
impl ConfigProvider for AlfredEnvProvider {
fn config(&self) -> Result<WorkflowConfig> {
let debug = env::var(VAR_DEBUG).unwrap_or_default();
let debug = debug == "1" || debug.to_lowercase() == "true";
if let Some(config) = try_env_config(debug) {
return Ok(config);
}
if let Some(config) = try_infer_from_binary(debug) {
return Ok(config);
}
Ok(temp_fallback_config(debug))
}
}
fn try_env_config(debug: bool) -> Option<WorkflowConfig> {
Some(WorkflowConfig {
workflow_bundleid: env::var(VAR_WORKFLOW_BUNDLEID).ok()?,
workflow_cache: env::var(VAR_WORKFLOW_CACHE).ok()?.into(),
workflow_data: env::var(VAR_WORKFLOW_DATA).ok()?.into(),
version: env::var(VAR_VERSION).ok()?,
version_build: env::var(VAR_VERSION_BUILD).ok()?,
workflow_name: env::var(VAR_WORKFLOW_NAME).ok()?,
workflow_version: env::var(VAR_WORKFLOW_VERSION).ok(),
preferences: env::var(VAR_PREFERENCES).ok(),
preferences_localhash: env::var(VAR_PREFERENCES_LOCALHASH).ok(),
theme: env::var(VAR_THEME).ok(),
theme_background: env::var(VAR_THEME_BACKGROUND).ok(),
theme_selection_background: env::var(VAR_THEME_SELECTION_BACKGROUND).ok(),
theme_subtext: env::var(VAR_THEME_SUBTEXT).ok(),
workflow_description: env::var(VAR_WORKFLOW_DESCRIPTION).ok(),
workflow_uid: env::var(VAR_WORKFLOW_UID).ok(),
workflow_keyword: env::var(VAR_WORKFLOW_KEYWORD).ok(),
debug,
})
}
fn try_infer_from_binary(debug: bool) -> Option<WorkflowConfig> {
let exe_path = env::current_exe().ok()?;
let plist_path = find_info_plist(&exe_path)?;
let (bundleid, name) = read_plist_metadata(&plist_path)?;
let home = dirs_for_bundleid(&bundleid);
Some(WorkflowConfig {
workflow_bundleid: bundleid,
workflow_cache: home.cache,
workflow_data: home.data,
version: DEFAULT_ALFRED_VERSION.to_string(),
version_build: DEFAULT_ALFRED_VERSION_BUILD.to_string(),
workflow_name: name,
workflow_version: None,
preferences: None,
preferences_localhash: None,
theme: None,
theme_background: None,
theme_selection_background: None,
theme_subtext: None,
workflow_description: None,
workflow_uid: None,
workflow_keyword: None,
debug,
})
}
fn temp_fallback_config(debug: bool) -> WorkflowConfig {
let binary_name = env::current_exe()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "alfrusco-workflow".to_string());
let base = env::temp_dir().join(&binary_name);
let cache = base.join("cache");
let data = base.join("data");
eprintln!(
"ERROR: alfrusco: No Alfred environment variables or info.plist found. \
Using ephemeral temp directories (data will not persist):\n \
cache: {}\n data: {}",
cache.display(),
data.display()
);
WorkflowConfig {
workflow_bundleid: format!("com.alfrusco.{binary_name}"),
workflow_cache: cache,
workflow_data: data,
version: DEFAULT_ALFRED_VERSION.to_string(),
version_build: DEFAULT_ALFRED_VERSION_BUILD.to_string(),
workflow_name: binary_name,
workflow_version: None,
preferences: None,
preferences_localhash: None,
theme: None,
theme_background: None,
theme_selection_background: None,
theme_subtext: None,
workflow_description: None,
workflow_uid: None,
workflow_keyword: None,
debug,
}
}
pub fn find_info_plist(exe_path: &Path) -> Option<PathBuf> {
let exe_dir = exe_path.parent()?;
let mut dir = exe_dir.to_path_buf();
for _ in 0..4 {
let candidate = dir.join("info.plist");
if candidate.is_file() {
return Some(candidate);
}
let workflow_candidate = dir.join("workflow").join("info.plist");
if workflow_candidate.is_file() {
return Some(workflow_candidate);
}
if !dir.pop() {
break;
}
}
None
}
pub fn read_plist_metadata(path: &Path) -> Option<(String, String)> {
let value = plist::Value::from_file(path).ok()?;
let dict = value.as_dictionary()?;
let bundleid = dict.get("bundleid")?.as_string()?.to_string();
let name = dict
.get("name")
.and_then(|v| v.as_string())
.unwrap_or("Unnamed Workflow")
.to_string();
Some((bundleid, name))
}
struct WorkflowDirs {
cache: PathBuf,
data: PathBuf,
}
fn dirs_for_bundleid(bundleid: &str) -> WorkflowDirs {
let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let home = PathBuf::from(home);
WorkflowDirs {
cache: home
.join("Library/Caches/com.runningwithcrayons.Alfred/Workflow Data")
.join(bundleid),
data: home
.join("Library/Application Support/Alfred/Workflow Data")
.join(bundleid),
}
}
pub struct TestingProvider(pub PathBuf);
impl ConfigProvider for TestingProvider {
fn config(&self) -> Result<WorkflowConfig> {
Ok(WorkflowConfig {
preferences: Some("/Users/Crayons/Dropbox/Alfred/Alfred.alfredpreferences".to_string()),
preferences_localhash: Some("adbd4f66bc3ae8493832af61a41ee609b20d8705".to_string()),
theme: Some("alfred.theme.yosemite".to_string()),
theme_background: Some("rgba(255,255,255,0.98)".to_string()),
theme_selection_background: Some("rgba(255,255,255,0.98)".to_string()),
theme_subtext: Some("3".to_string()),
version: "5.0".to_string(),
version_build: "2058".to_string(),
workflow_bundleid: "com.alfredapp.googlesuggest".to_string(),
workflow_cache: self.0.join("workflow_cache"),
workflow_data: self.0.join("workflow_data"),
workflow_name: "Test Workflow".to_string(),
workflow_description: Some(
"The description of the workflow we use for testing".to_string(),
),
workflow_version: Some("1.7".to_string()),
workflow_uid: Some("user.workflow.B0AC54EC-601C-479A-9428-01F9FD732959".to_string()),
workflow_keyword: None,
debug: true,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_alfred_env_provider_with_all_env_vars() {
temp_env::with_vars(
[
(VAR_WORKFLOW_CACHE, Some("/made/up/cache_dir")),
(VAR_WORKFLOW_DATA, Some("/made/up/data_dir")),
(VAR_WORKFLOW_BUNDLEID, Some("com.alfredapp.googlesuggest")),
(VAR_VERSION, Some("5.0")),
(VAR_VERSION_BUILD, Some("2058")),
(VAR_WORKFLOW_NAME, Some("Test Workflow")),
(VAR_WORKFLOW_VERSION, Some("1.7")),
(VAR_DEBUG, Some("true")),
],
|| {
let provider = AlfredEnvProvider;
let result = provider.config();
assert!(result.is_ok(), "{result:?}");
let config = result.unwrap();
assert_eq!(config.workflow_bundleid, "com.alfredapp.googlesuggest");
assert_eq!(config.workflow_cache, PathBuf::from("/made/up/cache_dir"));
},
);
}
#[test]
fn test_alfred_env_provider_falls_back_when_env_missing() {
temp_env::with_vars(
[
(VAR_WORKFLOW_CACHE, None::<&str>),
(VAR_WORKFLOW_DATA, None::<&str>),
(VAR_WORKFLOW_BUNDLEID, None::<&str>),
(VAR_VERSION, None::<&str>),
(VAR_VERSION_BUILD, None::<&str>),
(VAR_WORKFLOW_NAME, None::<&str>),
(VAR_DEBUG, None::<&str>),
],
|| {
let provider = AlfredEnvProvider;
let result = provider.config();
assert!(
result.is_ok(),
"Should not error with missing env: {result:?}"
);
let config = result.unwrap();
assert!(!config.workflow_bundleid.is_empty());
assert!(!config.workflow_name.is_empty());
},
);
}
#[test]
fn test_find_info_plist_in_same_directory() {
let dir = tempfile::tempdir().unwrap();
let plist_path = dir.path().join("info.plist");
std::fs::write(&plist_path, "").unwrap();
let exe_path = dir.path().join("mybinary");
let found = find_info_plist(&exe_path);
assert_eq!(found, Some(plist_path));
}
#[test]
fn test_find_info_plist_in_workflow_subdirectory() {
let dir = tempfile::tempdir().unwrap();
let workflow_dir = dir.path().join("workflow");
std::fs::create_dir_all(&workflow_dir).unwrap();
let plist_path = workflow_dir.join("info.plist");
std::fs::write(&plist_path, "").unwrap();
let exe_path = dir.path().join("mybinary");
let found = find_info_plist(&exe_path);
assert_eq!(found, Some(plist_path));
}
#[test]
fn test_find_info_plist_in_parent() {
let dir = tempfile::tempdir().unwrap();
let plist_path = dir.path().join("info.plist");
std::fs::write(&plist_path, "").unwrap();
let subdir = dir.path().join("bin");
std::fs::create_dir_all(&subdir).unwrap();
let exe_path = subdir.join("mybinary");
let found = find_info_plist(&exe_path);
assert_eq!(found, Some(plist_path));
}
#[test]
fn test_find_info_plist_returns_none_when_absent() {
let dir = tempfile::tempdir().unwrap();
let exe_path = dir.path().join("mybinary");
let found = find_info_plist(&exe_path);
assert_eq!(found, None);
}
#[test]
fn test_read_plist_metadata() {
let dir = tempfile::tempdir().unwrap();
let plist_path = dir.path().join("info.plist");
std::fs::write(
&plist_path,
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>bundleid</key>
<string>com.example.test</string>
<key>name</key>
<string>My Test Workflow</string>
</dict>
</plist>"#,
)
.unwrap();
let result = read_plist_metadata(&plist_path);
assert_eq!(
result,
Some((
"com.example.test".to_string(),
"My Test Workflow".to_string()
))
);
}
#[test]
fn test_read_plist_metadata_missing_bundleid() {
let dir = tempfile::tempdir().unwrap();
let plist_path = dir.path().join("info.plist");
std::fs::write(
&plist_path,
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>name</key>
<string>No Bundle ID</string>
</dict>
</plist>"#,
)
.unwrap();
let result = read_plist_metadata(&plist_path);
assert_eq!(result, None);
}
#[test]
fn test_testing_provider() {
let dir = tempfile::tempdir().unwrap().keep();
let provider = TestingProvider(dir);
let config = provider.config().unwrap();
assert_eq!(config.workflow_bundleid, "com.alfredapp.googlesuggest");
assert_eq!(config.workflow_name, "Test Workflow");
assert_eq!(config.version, "5.0");
assert_eq!(config.version_build, "2058");
}
}