# alfrusco
[](https://crates.io/crates/alfrusco)
[](https://docs.rs/alfrusco)
[](https://github.com/adlio/alfrusco/actions/workflows/ci.yml)
[](https://codecov.io/gh/adlio/alfrusco)
[](https://opensource.org/licenses/MIT)
A Rust library for building [Alfred](https://www.alfredapp.com/) workflows. It handles Alfred's JSON protocol, provides builder patterns for creating items, and includes support for background jobs, clipboard operations, and logging.
## Features
- 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
## Installation
Add alfrusco to your `Cargo.toml`:
```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"] }
```
## Quick Start
### Basic Synchronous Workflow
```rust
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());
}
```
### Async Workflow with HTTP Requests
```rust
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;
}
```
## Core Concepts
### Items
Items represent choices in the Alfred selection UI:
```rust
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");
```
### Workflow Configuration
Alfrusco handles Alfred's environment variables through configuration providers:
```rust
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());
```
### Error Handling
Custom error types integrate with Alfred:
```rust
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()))
}
}
```
## Advanced Features
### Background Jobs
Run tasks without blocking Alfred's UI:
```rust
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
URL items include modifiers for copying links in different formats:
```rust
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
### Filtering and Sorting
Enable fuzzy search:
```rust
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(())
}
}
```
#### Boosting Item Priority
Use boost to influence ranking:
```rust
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.
### Workflow Directories
```rust
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(())
}
}
```
### Response Caching and Rerun
```rust
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(())
}
}
```
## Testing
```rust
#[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"));
}
}
```
### Testing Workflow Navigation (Simulator)
For testing that actioning items produces correct navigation outcomes (drill-in, URL
open, dead-ends), use the `simulator` module. It parses your real `info.plist` and
walks the graph without needing the Alfred UI:
```rust
use alfrusco::simulator::{ActionResult, Simulator};
use alfrusco::{Item, Runnable, Workflow};
struct MyMenuWorkflow { category: Option<String> }
impl Runnable for MyMenuWorkflow {
type Error = alfrusco::Error;
fn run(self, wf: &mut Workflow) -> Result<(), Self::Error> {
match self.category.as_deref() {
Some("fruits") => {
wf.append_item(Item::new("Apple").arg("https://example.com/apple").valid(true));
}
_ => {
wf.append_item(Item::new("Fruits").arg("fruits").var("category", "fruits").valid(true));
}
}
Ok(())
}
}
#[test]
fn test_navigation_drill_in() {
// Point at your workflow directory (with info.plist)
let sim = Simulator::for_workflow_dir("workflow")
.unwrap()
.source_filter("SF-MAIN-001");
// Run in-process — no compiled binary or deployment needed
let screen = sim.run_in_process(MyMenuWorkflow { category: None }).unwrap();
screen.assert_renders();
// Verify the action routes to a sub-filter (drill-in)
let action = screen.action_first().unwrap();
action.assert_drills_in();
}
#[test]
fn test_navigation_opens_url() {
let sim = Simulator::for_workflow_dir("workflow")
.unwrap()
.source_filter("SF-SUB-001");
let screen = sim.run_in_process(MyMenuWorkflow { category: Some("fruits".into()) }).unwrap();
let action = screen.action_first().unwrap();
action.assert_opens_url();
}
```
The `alfrusco-simulator` CLI can also be used for ad-hoc auditing:
```bash
# Audit a workflow graph for navigation defects
alfrusco-simulator audit ./workflow
# Walk a workflow and show items with routing
alfrusco-simulator walk ./workflow --binary target/debug/myworkflow
alfrusco-simulator walk ./workflow --binary target/debug/myworkflow --source-filter SF-SUB-001 fruits
```
#### How the Audit Models Routing
The dynamic audit (`audit --binary`) faithfully models Alfred's routing semantics.
When a user actions an item, Alfred routes through the workflow graph using
conditionals and connections. The audit classifies each outcome and only flags
genuine dead-ends — never legitimate terminal actions.
**Conditional routing (matchmode evaluation)**
At each Conditional node, the item's `arg` is evaluated against conditions in order:
| 0 | Is | Exact equality (empty pattern → "is empty") |
| 1 | IsNot | Not equal (empty pattern → "is not empty") |
| 2 | Contains | Substring match |
| 3 | DoesNotContain | No substring match |
| 4 | StartsWith | Prefix match |
| 5 | EndsWith | Suffix match |
| 6 | MatchesRegex | Regular expression match |
The first matching condition's output port determines which connection to follow.
If no condition matches, the else branch is taken.
**External Trigger re-entry (drill-in via triggers)**
A `CallExternalTrigger` output resolves to its matching `ExternalTrigger` input
(by trigger ID) and continues traversal from there. The chain:
```
item → Conditional → CallExternalTrigger → ExternalTrigger → Script Filter
```
classifies as **DrilledIn** — a legitimate drill-in navigation, not a dead-end.
**Terminal classification**
| DrilledIn | Reached another Script Filter | ✅ OK |
| TypedAutocomplete | `valid:false` + autocomplete | ✅ OK |
| RanScript | Reached a Run Script (act-and-exit)| ✅ OK |
| OpenedUrl | Reached Open URL (act-and-exit) | ✅ OK |
| DeadEnd | Dangling/unconnected branch output | ❌ **ERROR** |
**Only `DeadEnd` is an error.** This occurs when the matched conditional branch
connects to a non-existent object or has no connection at all — actioning the item
silently does nothing. Items that route to Run Script or Open URL are intentional
act-and-exit patterns (e.g. copy to clipboard, open a URL, run a mutation) and are
never flagged.
## Examples
The `examples/` directory contains runnable examples. They require Alfred environment variables:
```bash
# 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_output
```
## API Reference
### `Item`
- `new(title)` - Create item
- `subtitle(text)` - Set subtitle
- `arg(value)` / `args(values)` - Set arguments
- `valid(bool)` - Set actionable
- `uid(id)` - Set unique identifier
- `icon_from_image(path)` / `icon_for_filetype(type)` - Set icons
- `copy_text(text)` / `large_type_text(text)` - Set text operations
- `quicklook_url(url)` - Enable Quick Look
- `var(key, value)` - Set workflow variables
- `autocomplete(text)` - Set tab completion
- `modifier(modifier)` - Add modifier actions
- `sticky(bool)` - Pin to top
- `boost(value)` - Adjust ranking
### `URLItem`
- `new(title, url)` - Create URL item
- `subtitle(text)` - Override subtitle
- `short_title(text)` / `long_title(text)` - Alternative titles for modifiers
- `display_title(text)` - Override display title
- `copy_text(text)` - Set copy text
- `icon_from_image(path)` / `icon_for_filetype(type)` - Set icons
### `Workflow`
- `append_item(item)` / `append_items(items)` - Add items
- `prepend_item(item)` / `prepend_items(items)` - Add items to beginning
- `set_filter_keyword(query)` - Enable filtering
- `data_dir()` / `cache_dir()` - Get directories
- `run_in_background(name, max_age, command)` - Run background job
### `Response`
- `cache(duration, loose_reload)` - Set caching
- `rerun(interval)` - Set refresh interval
- `skip_knowledge(bool)` - Control knowledge integration
### Traits
```rust
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 */ }
}
```
### Configuration
- `AlfredEnvProvider` - Reads from Alfred environment variables
- `TestingProvider` - Uses temporary directories
### Execution
- `execute(provider, runnable, writer)` - Run synchronous workflow
- `execute_async(provider, runnable, writer)` - Run async workflow
- `init_logging(provider)` - Initialize logging
## Development
```bash
git clone https://github.com/adlio/alfrusco.git
cd alfrusco
cargo build
cargo test
cargo nextest run # recommended
cargo tarpaulin --out html # coverage
```
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make changes and add tests
4. Run `cargo nextest run`, `cargo clippy`, `cargo fmt`
5. Submit a pull request
## License
MIT License - see [LICENSE](LICENSE).
## Support
- [Documentation](https://docs.rs/alfrusco)
- [Issues](https://github.com/adlio/alfrusco/issues)
- [Discussions](https://github.com/adlio/alfrusco/discussions)