use std::path::{Path, PathBuf};
use std::process;
use clap::{Parser, Subcommand};
use alfrusco::simulator::{ActionResult, Severity, Simulator, WorkflowGraph};
#[derive(Parser)]
#[command(
name = "alfrusco-simulator",
about = "Headless Alfred workflow simulator"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Audit {
dir: PathBuf,
#[arg(long)]
binary: Option<PathBuf>,
},
Walk {
dir: PathBuf,
#[arg(long)]
binary: PathBuf,
#[arg(long)]
source_filter: Option<String>,
query: Vec<String>,
},
}
fn main() {
let cli = Cli::parse();
match cli.command {
Commands::Audit { dir, binary } => run_audit(&dir, binary.as_deref()),
Commands::Walk {
dir,
binary,
source_filter,
query,
} => run_walk(&dir, &binary, source_filter.as_deref(), &query),
}
}
fn run_audit(dir: &Path, binary: Option<&Path>) {
let plist_path = dir.join("info.plist");
let graph = match WorkflowGraph::from_plist_file(&plist_path) {
Ok(g) => g,
Err(e) => {
eprintln!("ERROR: failed to parse {}: {e}", plist_path.display());
process::exit(1);
}
};
let keywords: Vec<String> = graph
.objects()
.values()
.filter_map(|n| n.keyword.clone())
.collect();
println!("Auditing: {}", plist_path.display());
println!(
"Objects: {} Connections: {} Keywords: {}",
graph.objects().len(),
graph.connections().len(),
keywords.len()
);
println!();
if keywords.is_empty() {
println!("WARNING: no keywords found in workflow graph.");
return;
}
let keyword_refs: Vec<&str> = keywords.iter().map(String::as_str).collect();
let diagnostics = graph.audit_navigation(&keyword_refs);
let mut has_errors = false;
if !diagnostics.is_empty() {
println!("Static analysis: {} issue(s):", diagnostics.len());
println!();
for d in &diagnostics {
let severity_str = match d.severity {
Severity::Error => "Error",
Severity::Warning => "Warning",
Severity::Info => "Info",
};
println!(" [{severity_str}] {}", d.message);
if d.severity >= Severity::Error {
has_errors = true;
}
}
println!();
}
if let Some(binary_path) = binary {
let sim = match Simulator::for_workflow_dir(dir) {
Ok(s) => s.binary(binary_path),
Err(e) => {
eprintln!("ERROR: {e}");
process::exit(1);
}
};
match sim.dynamic_audit() {
Ok(dyn_diagnostics) => {
if !dyn_diagnostics.is_empty() {
println!("Dynamic analysis: {} issue(s):", dyn_diagnostics.len());
println!();
for d in &dyn_diagnostics {
let severity_str = match d.severity {
Severity::Error => "Error",
Severity::Warning => "Warning",
Severity::Info => "Info",
};
println!(" [{severity_str}] {}", d.message);
if d.severity >= Severity::Error {
has_errors = true;
}
}
println!();
}
}
Err(e) => {
eprintln!("ERROR: dynamic audit failed: {e}");
process::exit(1);
}
}
}
if has_errors {
process::exit(1);
} else if diagnostics.is_empty() && binary.is_none() {
println!("✓ No navigation defects found.");
} else if diagnostics.is_empty() {
println!("✓ No navigation defects found (static + dynamic).");
}
}
fn run_walk(dir: &Path, binary: &Path, source_filter: Option<&str>, query: &[String]) {
let sim = match Simulator::for_workflow_dir(dir) {
Ok(s) => s.binary(binary),
Err(e) => {
eprintln!("ERROR: {e}");
process::exit(1);
}
};
let sim = if let Some(uid) = source_filter {
sim.source_filter(uid)
} else {
sim
};
let args: Vec<&str> = query.iter().map(String::as_str).collect();
let screen = match sim.invoke(&args) {
Ok(s) => s,
Err(e) => {
eprintln!("ERROR: workflow invocation failed: {e}");
process::exit(1);
}
};
println!(
"Walk: {} --binary {} {}",
dir.display(),
binary.display(),
query.join(" ")
);
println!("Items: {}", screen.len());
println!();
for (i, item) in screen.items().iter().enumerate() {
let valid = if item.is_valid() { "✓" } else { "○" };
println!(" [{i}] {valid} {}", item.title());
if let Some(sub) = item.subtitle() {
println!(" subtitle: {sub}");
}
if let Some(arg) = item.arg() {
println!(" arg: {arg}");
}
if let Some(ac) = item.autocomplete() {
println!(" autocomplete: {ac}");
}
if let Some(action) = screen.action(i) {
let routing = match &action {
ActionResult::DrilledIn { target_uid } => {
format!("→ DrilledIn({target_uid})")
}
ActionResult::OpenedUrl { url_template } => {
format!("→ OpenedUrl({url_template})")
}
ActionResult::RanScript { target_uid } => {
format!("→ RanScript({target_uid})")
}
ActionResult::TypedAutocomplete { text } => {
format!("→ TypedAutocomplete(\"{text}\")")
}
ActionResult::DeadEnd => "→ DeadEnd ⚠".to_string(),
};
println!(" route: {routing}");
}
println!();
}
}