A Rust library for building Alfred workflows. It handles Alfred's JSON protocol, provides builder patterns for creating items, and includes support for background jobs, clipboard operations, and logging.
- Builder patterns for creating Alfred items
- Async/await support
- Background jobs that don't block Alfred's UI
- Rich text and Markdown clipboard operations
- Fuzzy search and sorting
- Access to workflow directories and configuration
- Structured logging
- URL items with clipboard modifiers
- Testing utilities
Add alfrusco to your Cargo.toml:
[dependencies]
alfrusco = "0.3"
# For async workflows
tokio = { version = "1", features = ["full"] }
# For command-line argument parsing (recommended)
clap = { version = "4", features = ["derive", "env"] }use alfrusco::{execute, Item, Runnable, Workflow};
use alfrusco::config::AlfredEnvProvider;
use clap::Parser;
#[derive(Parser)]
struct MyWorkflow {
query: Vec<String>,
}
impl Runnable for MyWorkflow {
type Error = alfrusco::Error;
fn run(self, workflow: &mut Workflow) -> Result<(), Self::Error> {
let query = self.query.join(" ");
workflow.append_item(
Item::new(format!("Hello, {}!", query))
.subtitle("This is a basic Alfred workflow")
.arg(&query)
.valid(true)
);
Ok(())
}
}
fn main() {
let _ = alfrusco::init_logging(&AlfredEnvProvider);
let command = MyWorkflow::parse();
execute(&AlfredEnvProvider, command, &mut std::io::stdout());
}use alfrusco::{execute_async, AsyncRunnable, Item, Workflow, WorkflowError};
use alfrusco::config::AlfredEnvProvider;
use clap::Parser;
use serde::Deserialize;
#[derive(Parser)]
struct ApiWorkflow {
query: Vec<String>,
}
#[derive(Deserialize)]
struct ApiResponse {
results: Vec<ApiResult>,
}
#[derive(Deserialize)]
struct ApiResult {
title: String,
description: String,
url: String,
}
#[async_trait::async_trait]
impl AsyncRunnable for ApiWorkflow {
type Error = Box<dyn WorkflowError>;
async fn run_async(self, workflow: &mut Workflow) -> Result<(), Self::Error> {
let query = self.query.join(" ");
workflow.set_filter_keyword(query.clone());
let url = format!("https://api.example.com/search?q={}", query);
let response: ApiResponse = reqwest::get(&url)
.await?
.json()
.await?;
let items: Vec<Item> = response.results
.into_iter()
.map(|result| {
Item::new(&result.title)
.subtitle(&result.description)
.arg(&result.url)
.quicklook_url(&result.url)
.valid(true)
})
.collect();
workflow.append_items(items);
Ok(())
}
}
#[tokio::main]
async fn main() {
let _ = alfrusco::init_logging(&AlfredEnvProvider);
let command = ApiWorkflow::parse();
execute_async(&AlfredEnvProvider, command, &mut std::io::stdout()).await;
}Items represent choices in the Alfred selection UI:
use alfrusco::Item;
let item = Item::new("My Title")
.subtitle("Additional information")
.arg("argument-passed-to-action")
.uid("unique-identifier")
.valid(true)
.icon_from_image("/path/to/icon.png")
.copy_text("Text copied with Cmd+C")
.large_type_text("Text shown in large type with Cmd+L")
.quicklook_url("https://example.com")
.var("CUSTOM_VAR", "value")
.autocomplete("text for tab completion");Alfrusco handles Alfred's environment variables through configuration providers:
use alfrusco::config::{AlfredEnvProvider, TestingProvider};
// For production (reads from Alfred environment variables)
let provider = AlfredEnvProvider;
// For testing (uses temporary directories)
let temp_dir = tempfile::tempdir().unwrap();
let provider = TestingProvider(temp_dir.path().to_path_buf());Custom error types integrate with Alfred:
use alfrusco::{WorkflowError, Item};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum MyWorkflowError {
#[error("Network request failed: {0}")]
Network(#[from] reqwest::Error),
#[error("Invalid input: {0}")]
InvalidInput(String),
}
impl WorkflowError for MyWorkflowError {}
// Errors become Alfred items automatically
impl Runnable for MyWorkflow {
type Error = MyWorkflowError;
fn run(self, workflow: &mut Workflow) -> Result<(), Self::Error> {
Err(MyWorkflowError::InvalidInput("Missing required field".to_string()))
}
}Run tasks without blocking Alfred's UI:
use std::process::Command;
use std::time::Duration;
impl Runnable for MyWorkflow {
type Error = alfrusco::Error;
fn run(self, workflow: &mut Workflow) -> Result<(), Self::Error> {
let cache_file = workflow.cache_dir().join("releases.json");
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(format!(
"curl -s https://api.github.com/repos/rust-lang/rust/releases/latest > {}",
cache_file.display()
));
// Run in background, refresh every 30 seconds
workflow.run_in_background(
"github-releases",
Duration::from_secs(30),
cmd
);
if cache_file.exists() {
if let Ok(data) = std::fs::read_to_string(&cache_file) {
if let Ok(release) = serde_json::from_str::<serde_json::Value>(&data) {
if let Some(tag) = release["tag_name"].as_str() {
workflow.append_item(
Item::new(format!("Latest Rust: {}", tag))
.subtitle("Click to view release notes")
.arg(release["html_url"].as_str().unwrap_or(""))
.valid(true)
);
}
}
}
}
Ok(())
}
}Background jobs track their status and show messages like "Last succeeded 2 minutes ago, running for 3s". Failed jobs are retried automatically.
URL items include modifiers for copying links in different formats:
use alfrusco::URLItem;
let url_item = URLItem::new("Rust Documentation", "https://doc.rust-lang.org/")
.subtitle("The Rust Programming Language Documentation")
.short_title("Rust Docs")
.long_title("The Rust Programming Language Official Documentation")
.icon_for_filetype("public.html")
.copy_text("doc.rust-lang.org");
let item: Item = url_item.into();Modifier keys:
- Cmd: Copy as Markdown link
- Alt: Copy as rich text link
- Cmd+Shift: Copy as Markdown with short title
- Alt+Shift: Copy as rich text with short title
- Cmd+Ctrl: Copy as Markdown with long title
- Alt+Ctrl: Copy as rich text with long title
Enable fuzzy search:
impl Runnable for SearchWorkflow {
type Error = alfrusco::Error;
fn run(self, workflow: &mut Workflow) -> Result<(), Self::Error> {
let query = self.query.join(" ");
workflow.set_filter_keyword(query);
workflow.append_items(vec![
Item::new("Apple").subtitle("Fruit"),
Item::new("Banana").subtitle("Yellow fruit"),
Item::new("Carrot").subtitle("Orange vegetable"),
]);
Ok(())
}
}Use boost to influence ranking:
use alfrusco::{Item, BOOST_HIGH, BOOST_MODERATE};
workflow.append_items(vec![
Item::new("Preferred Result")
.subtitle("This ranks higher")
.boost(BOOST_HIGH),
Item::new("Normal Result")
.subtitle("Standard ranking"),
Item::new("Slightly Preferred")
.subtitle("Moderate boost")
.boost(BOOST_MODERATE),
]);Boost constants:
BOOST_SLIGHT(25)BOOST_LOW(50)BOOST_MODERATE(75)BOOST_HIGH(100)BOOST_HIGHER(150)BOOST_HIGHEST(200)
Boost only affects non-sticky items. Use .sticky(true) for items that should always appear first.
impl Runnable for MyWorkflow {
type Error = alfrusco::Error;
fn run(self, workflow: &mut Workflow) -> Result<(), Self::Error> {
let data_dir = workflow.data_dir();
let config_file = data_dir.join("config.json");
let cache_dir = workflow.cache_dir();
let temp_file = cache_dir.join("temp_data.json");
std::fs::write(config_file, "{\"setting\": \"value\"}")?;
Ok(())
}
}use std::time::Duration;
impl Runnable for MyWorkflow {
type Error = alfrusco::Error;
fn run(self, workflow: &mut Workflow) -> Result<(), Self::Error> {
workflow.cache(Duration::from_secs(300), true);
workflow.rerun(Duration::from_secs(30));
workflow.skip_knowledge(true);
workflow.append_item(Item::new("Cached result"));
Ok(())
}
}#[cfg(test)]
mod tests {
use super::*;
use alfrusco::config::TestingProvider;
use tempfile::tempdir;
#[test]
fn test_my_workflow() {
let workflow = MyWorkflow {
query: vec!["test".to_string()],
};
let temp_dir = tempdir().unwrap();
let provider = TestingProvider(temp_dir.path().to_path_buf());
let mut buffer = Vec::new();
alfrusco::execute(&provider, workflow, &mut buffer);
let output = String::from_utf8(buffer).unwrap();
assert!(output.contains("Hello, test!"));
}
#[tokio::test]
async fn test_async_workflow() {
let workflow = AsyncWorkflow {
query: vec!["async".to_string()],
};
let temp_dir = tempdir().unwrap();
let provider = TestingProvider(temp_dir.path().to_path_buf());
let mut buffer = Vec::new();
alfrusco::execute_async(&provider, workflow, &mut buffer).await;
let output = String::from_utf8(buffer).unwrap();
assert!(output.contains("async"));
}
}The examples/ directory contains runnable examples. They require Alfred environment variables:
# Using the run script
./run-example.sh static_output
./run-example.sh success --message "Custom message"
./run-example.sh random_user search_term
./run-example.sh url_items
./run-example.sh sleep --duration-in-seconds 10
./run-example.sh error --file-path nonexistent.txt
# Using Make
make examples-help
make example-static_output
# Manual setup
export alfred_workflow_bundleid="com.example.test"
export alfred_workflow_cache="/tmp/cache"
export alfred_workflow_data="/tmp/data"
export alfred_version="5.0"
export alfred_version_build="2058"
export alfred_workflow_name="Test Workflow"
cargo run --example static_outputnew(title)- Create itemsubtitle(text)- Set subtitlearg(value)/args(values)- Set argumentsvalid(bool)- Set actionableuid(id)- Set unique identifiericon_from_image(path)/icon_for_filetype(type)- Set iconscopy_text(text)/large_type_text(text)- Set text operationsquicklook_url(url)- Enable Quick Lookvar(key, value)- Set workflow variablesautocomplete(text)- Set tab completionmodifier(modifier)- Add modifier actionssticky(bool)- Pin to topboost(value)- Adjust ranking
new(title, url)- Create URL itemsubtitle(text)- Override subtitleshort_title(text)/long_title(text)- Alternative titles for modifiersdisplay_title(text)- Override display titlecopy_text(text)- Set copy texticon_from_image(path)/icon_for_filetype(type)- Set icons
append_item(item)/append_items(items)- Add itemsprepend_item(item)/prepend_items(items)- Add items to beginningset_filter_keyword(query)- Enable filteringdata_dir()/cache_dir()- Get directoriesrun_in_background(name, max_age, command)- Run background job
cache(duration, loose_reload)- Set cachingrerun(interval)- Set refresh intervalskip_knowledge(bool)- Control knowledge integration
trait Runnable {
type Error: WorkflowError;
fn run(self, workflow: &mut Workflow) -> Result<(), Self::Error>;
}
#[async_trait]
trait AsyncRunnable {
type Error: WorkflowError;
async fn run_async(self, workflow: &mut Workflow) -> Result<(), Self::Error>;
}
trait WorkflowError: std::error::Error {
fn error_item(&self) -> Item { /* default implementation */ }
}AlfredEnvProvider- Reads from Alfred environment variablesTestingProvider- Uses temporary directories
execute(provider, runnable, writer)- Run synchronous workflowexecute_async(provider, runnable, writer)- Run async workflowinit_logging(provider)- Initialize logging
git clone https://github.com/adlio/alfrusco.git
cd alfrusco
cargo build
cargo test
cargo nextest run # recommended
cargo tarpaulin --out html # coverage- Fork the repository
- Create a feature branch
- Make changes and add tests
- Run
cargo nextest run,cargo clippy,cargo fmt - Submit a pull request
MIT License - see LICENSE.