use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ObjectKind {
ScriptFilter,
Keyword,
OpenUrl,
RunScript,
Conditional,
Clipboard,
CallExternalTrigger,
ExternalTrigger,
Other(String),
}
impl ObjectKind {
fn from_type_string(s: &str) -> Self {
match s {
"alfred.workflow.input.scriptfilter" => Self::ScriptFilter,
"alfred.workflow.input.keyword" => Self::Keyword,
"alfred.workflow.action.openurl" => Self::OpenUrl,
"alfred.workflow.action.script" => Self::RunScript,
"alfred.workflow.utility.conditional" => Self::Conditional,
"alfred.workflow.output.clipboard" => Self::Clipboard,
"alfred.workflow.output.callexternaltrigger" => Self::CallExternalTrigger,
"alfred.workflow.trigger.external" => Self::ExternalTrigger,
other => Self::Other(other.to_string()),
}
}
}
#[derive(Debug, Clone)]
pub struct ObjectNode {
pub uid: String,
pub kind: ObjectKind,
pub keyword: Option<String>,
pub title: Option<String>,
pub conditions: Vec<Condition>,
config_strings: HashMap<String, String>,
}
impl ObjectNode {
pub fn config_value(&self, key: &str) -> Option<&str> {
self.config_strings.get(key).map(String::as_str)
}
pub fn script_file(&self) -> Option<&str> {
self.config_value("scriptfile").filter(|s| !s.is_empty())
}
pub fn script(&self) -> Option<&str> {
self.config_value("script").filter(|s| !s.is_empty())
}
}
#[derive(Debug, Clone)]
pub struct Connection {
pub source_uid: String,
pub destination_uid: String,
pub modifiers: u64,
pub source_output_uid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Condition {
pub uid: Option<String>,
pub input_string: String,
pub match_mode: MatchMode,
pub match_string: String,
pub match_case_sensitive: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchMode {
Is,
IsNot,
Contains,
DoesNotContain,
StartsWith,
EndsWith,
MatchesRegex,
Unknown(u64),
}
impl MatchMode {
pub fn from_integer(n: u64) -> Self {
match n {
0 => Self::Is,
1 => Self::IsNot,
2 => Self::Contains,
3 => Self::DoesNotContain,
4 => Self::StartsWith,
5 => Self::EndsWith,
6 => Self::MatchesRegex,
other => Self::Unknown(other),
}
}
pub fn evaluate(&self, input: &str, pattern: &str, case_sensitive: bool) -> bool {
match self {
Self::Is => {
if pattern.is_empty() {
return input.is_empty();
}
if case_sensitive {
input == pattern
} else {
input.eq_ignore_ascii_case(pattern)
}
}
Self::IsNot => {
if pattern.is_empty() {
return !input.is_empty();
}
if case_sensitive {
input != pattern
} else {
!input.eq_ignore_ascii_case(pattern)
}
}
Self::Contains => {
if case_sensitive {
input.contains(pattern)
} else {
input
.to_ascii_lowercase()
.contains(&pattern.to_ascii_lowercase())
}
}
Self::DoesNotContain => {
if case_sensitive {
!input.contains(pattern)
} else {
!input
.to_ascii_lowercase()
.contains(&pattern.to_ascii_lowercase())
}
}
Self::StartsWith => {
if case_sensitive {
input.starts_with(pattern)
} else {
input
.to_ascii_lowercase()
.starts_with(&pattern.to_ascii_lowercase())
}
}
Self::EndsWith => {
if case_sensitive {
input.ends_with(pattern)
} else {
input
.to_ascii_lowercase()
.ends_with(&pattern.to_ascii_lowercase())
}
}
Self::MatchesRegex => {
let pattern_str = if case_sensitive {
pattern.to_string()
} else {
format!("(?i){pattern}")
};
regex::Regex::new(&pattern_str).is_ok_and(|re| re.is_match(input))
}
Self::Unknown(_) => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone)]
pub struct AuditDiagnostic {
pub severity: Severity,
pub message: String,
pub object_uid: Option<String>,
}
#[derive(Debug, Clone)]
pub struct WorkflowGraph {
objects: HashMap<String, ObjectNode>,
connections: Vec<Connection>,
}
impl WorkflowGraph {
pub fn from_plist_file(path: impl AsRef<Path>) -> Result<Self, GraphError> {
let value = plist::Value::from_file(path.as_ref())
.map_err(|e| GraphError::PlistParse(e.to_string()))?;
let dict = value
.as_dictionary()
.ok_or_else(|| GraphError::PlistParse("root is not a dictionary".to_string()))?;
Self::from_plist_dict(dict)
}
fn from_plist_dict(dict: &plist::Dictionary) -> Result<Self, GraphError> {
let mut objects = HashMap::new();
let mut connections = Vec::new();
if let Some(objs) = dict.get("objects").and_then(|v| v.as_array()) {
for obj in objs {
if let Some(node) = Self::parse_object(obj) {
objects.insert(node.uid.clone(), node);
}
}
}
if let Some(conns) = dict.get("connections").and_then(|v| v.as_dictionary()) {
for (source_uid, destinations) in conns {
if let Some(dests) = destinations.as_array() {
for dest in dests {
if let Some(dest_dict) = dest.as_dictionary() {
let destination_uid = dest_dict
.get("destinationuid")
.and_then(|v| v.as_string())
.unwrap_or_default()
.to_string();
let modifiers = dest_dict
.get("modifiers")
.and_then(|v| v.as_unsigned_integer())
.unwrap_or(0);
let source_output_uid = dest_dict
.get("sourceoutputuid")
.and_then(|v| v.as_string())
.map(String::from);
connections.push(Connection {
source_uid: source_uid.clone(),
destination_uid,
modifiers,
source_output_uid,
});
}
}
}
}
}
Ok(Self {
objects,
connections,
})
}
fn parse_object(value: &plist::Value) -> Option<ObjectNode> {
let dict = value.as_dictionary()?;
let uid = dict.get("uid")?.as_string()?.to_string();
let type_str = dict.get("type")?.as_string()?;
let kind = ObjectKind::from_type_string(type_str);
let config = dict.get("config").and_then(|v| v.as_dictionary());
let keyword = config
.and_then(|c| c.get("keyword"))
.and_then(|v| v.as_string())
.map(String::from)
.filter(|s| !s.is_empty());
let title = config
.and_then(|c| c.get("title"))
.and_then(|v| v.as_string())
.map(String::from);
let conditions = if kind == ObjectKind::Conditional {
Self::parse_conditions(config)
} else {
Vec::new()
};
let config_strings = config
.map(|c| {
c.iter()
.filter_map(|(k, v)| v.as_string().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
Some(ObjectNode {
uid,
kind,
keyword,
title,
conditions,
config_strings,
})
}
fn parse_conditions(config: Option<&plist::Dictionary>) -> Vec<Condition> {
let Some(config) = config else {
return Vec::new();
};
let Some(conditions_array) = config.get("conditions").and_then(|v| v.as_array()) else {
return Vec::new();
};
conditions_array
.iter()
.filter_map(|v| {
let d = v.as_dictionary()?;
let input_string = d
.get("inputstring")
.and_then(|v| v.as_string())
.unwrap_or("{query}")
.to_string();
let match_mode = MatchMode::from_integer(
d.get("matchmode")
.and_then(|v| v.as_unsigned_integer())
.unwrap_or(0),
);
let match_string = d
.get("matchstring")
.and_then(|v| v.as_string())
.unwrap_or("")
.to_string();
let match_case_sensitive = d
.get("matchcasesensitive")
.and_then(|v| v.as_boolean())
.unwrap_or(false);
let uid = d.get("uid").and_then(|v| v.as_string()).map(String::from);
Some(Condition {
uid,
input_string,
match_mode,
match_string,
match_case_sensitive,
})
})
.collect()
}
pub fn objects(&self) -> &HashMap<String, ObjectNode> {
&self.objects
}
pub fn connections(&self) -> &[Connection] {
&self.connections
}
pub fn script_filter_uid(&self, keyword: &str) -> Option<&str> {
self.objects.values().find_map(|node| {
if node.kind == ObjectKind::ScriptFilter && node.keyword.as_deref() == Some(keyword) {
Some(node.uid.as_str())
} else {
None
}
})
}
pub fn reachable_kinds(&self, uid: &str) -> HashSet<ObjectKind> {
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
let mut kinds = HashSet::new();
for conn in &self.connections {
if conn.source_uid == uid && !visited.contains(&conn.destination_uid) {
visited.insert(conn.destination_uid.clone());
queue.push_back(conn.destination_uid.clone());
}
}
while let Some(current) = queue.pop_front() {
if let Some(node) = self.objects.get(¤t) {
kinds.insert(node.kind.clone());
if node.kind == ObjectKind::CallExternalTrigger {
if let Some(trigger_id) = node.config_value("externaltriggerid") {
if let Some(trigger_uid) = self.external_trigger_uid(trigger_id) {
if !visited.contains(trigger_uid) {
visited.insert(trigger_uid.to_string());
queue.push_back(trigger_uid.to_string());
}
}
}
}
}
for conn in &self.connections {
if conn.source_uid == current && !visited.contains(&conn.destination_uid) {
visited.insert(conn.destination_uid.clone());
queue.push_back(conn.destination_uid.clone());
}
}
}
kinds
}
pub fn reaches_script_filter(&self, uid: &str) -> bool {
let kinds = self.reachable_kinds(uid);
kinds.contains(&ObjectKind::ScriptFilter)
}
pub fn audit_navigation(&self, keywords: &[&str]) -> Vec<AuditDiagnostic> {
let mut diagnostics = Vec::new();
let filters: Vec<&ObjectNode> = if keywords.is_empty() {
self.objects
.values()
.filter(|n| n.kind == ObjectKind::ScriptFilter)
.collect()
} else {
keywords
.iter()
.filter_map(|kw| {
self.script_filter_uid(kw)
.and_then(|uid| self.objects.get(uid))
})
.collect()
};
for filter in &filters {
let outgoing: Vec<&Connection> = self
.connections
.iter()
.filter(|c| c.source_uid == filter.uid)
.collect();
if outgoing.is_empty() {
diagnostics.push(AuditDiagnostic {
severity: Severity::Error,
message: format!(
"Script Filter '{}' ({}) has no outgoing connections (dead-end)",
filter.keyword.as_deref().unwrap_or("?"),
filter.uid,
),
object_uid: Some(filter.uid.clone()),
});
continue;
}
let reachable = self.reachable_kinds(&filter.uid);
let has_action = reachable.contains(&ObjectKind::OpenUrl)
|| reachable.contains(&ObjectKind::RunScript)
|| reachable.contains(&ObjectKind::Clipboard);
let has_drill_in = reachable.contains(&ObjectKind::ScriptFilter);
if !has_action && !has_drill_in {
diagnostics.push(AuditDiagnostic {
severity: Severity::Warning,
message: format!(
"Script Filter '{}' ({}) cannot reach any action or another Script Filter",
filter.keyword.as_deref().unwrap_or("?"),
filter.uid,
),
object_uid: Some(filter.uid.clone()),
});
}
}
for conn in &self.connections {
if !self.objects.contains_key(&conn.destination_uid) {
diagnostics.push(AuditDiagnostic {
severity: Severity::Error,
message: format!(
"Connection from '{}' points to non-existent object '{}'",
conn.source_uid, conn.destination_uid,
),
object_uid: Some(conn.source_uid.clone()),
});
}
}
diagnostics
}
pub fn external_trigger_uid(&self, trigger_id: &str) -> Option<&str> {
self.objects.values().find_map(|node| {
if node.kind == ObjectKind::ExternalTrigger
&& node.config_value("triggerid") == Some(trigger_id)
{
Some(node.uid.as_str())
} else {
None
}
})
}
pub fn outgoing_connections(&self, uid: &str, modifiers: Option<u64>) -> Vec<&Connection> {
self.connections
.iter()
.filter(|c| c.source_uid == uid && modifiers.is_none_or(|m| c.modifiers == m))
.collect()
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum GraphError {
#[error("failed to parse info.plist: {0}")]
PlistParse(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_existing_workflow_plist() {
let graph = WorkflowGraph::from_plist_file("workflow/info.plist").unwrap();
assert_eq!(graph.objects().len(), 2);
assert_eq!(graph.connections().len(), 1);
}
#[test]
fn script_filter_uid_by_keyword() {
let graph = WorkflowGraph::from_plist_file("workflow/info.plist").unwrap();
let uid = graph.script_filter_uid("sleep");
assert_eq!(uid, Some("1683B57E-4C4A-402A-AECB-D493E46FE968"));
}
#[test]
fn script_filter_uid_nonexistent_keyword() {
let graph = WorkflowGraph::from_plist_file("workflow/info.plist").unwrap();
assert_eq!(graph.script_filter_uid("nonexistent"), None);
}
#[test]
fn reachable_kinds_from_script_filter() {
let graph = WorkflowGraph::from_plist_file("workflow/info.plist").unwrap();
let kinds = graph.reachable_kinds("1683B57E-4C4A-402A-AECB-D493E46FE968");
assert!(kinds.contains(&ObjectKind::OpenUrl));
assert!(!kinds.contains(&ObjectKind::ScriptFilter));
}
#[test]
fn reaches_script_filter_false_when_only_action() {
let graph = WorkflowGraph::from_plist_file("workflow/info.plist").unwrap();
assert!(!graph.reaches_script_filter("1683B57E-4C4A-402A-AECB-D493E46FE968"));
}
#[test]
fn audit_navigation_clean_workflow() {
let graph = WorkflowGraph::from_plist_file("workflow/info.plist").unwrap();
let diagnostics = graph.audit_navigation(&[]);
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
}
#[test]
fn outgoing_connections_default() {
let graph = WorkflowGraph::from_plist_file("workflow/info.plist").unwrap();
let conns = graph.outgoing_connections("1683B57E-4C4A-402A-AECB-D493E46FE968", Some(0));
assert_eq!(conns.len(), 1);
assert_eq!(
conns[0].destination_uid,
"513B7861-747E-4F95-A5BC-6AE622EEB1BF"
);
}
}