alfrusco 0.4.2

Utilities for building Alfred workflows with Rust.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
# alfrusco

[![Crates.io](https://img.shields.io/crates/v/alfrusco.svg)](https://crates.io/crates/alfrusco)
[![Documentation](https://docs.rs/alfrusco/badge.svg)](https://docs.rs/alfrusco)
[![CI](https://github.com/adlio/alfrusco/actions/workflows/ci.yml/badge.svg)](https://github.com/adlio/alfrusco/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/adlio/alfrusco/graph/badge.svg)](https://codecov.io/gh/adlio/alfrusco)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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:

| Matchmode | Name           | Semantics                                      |
|-----------|----------------|------------------------------------------------|
| 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**

| Outcome           | Meaning                            | Audit result |
|-------------------|------------------------------------|--------------|
| 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