#[cfg(not(target_os = "windows"))]
fn main() {
eprintln!("workflow runner only supports Windows.");
std::process::exit(1);
}
#[cfg(target_os = "windows")]
static CANCEL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[cfg(target_os = "windows")]
unsafe extern "system" fn ctrl_handler(ctrl_type: u32) -> windows::core::BOOL {
use std::sync::atomic::Ordering;
if ctrl_type == 0 {
CANCEL.store(true, Ordering::Relaxed);
windows::core::BOOL(1) } else {
windows::core::BOOL(0) }
}
#[cfg(target_os = "windows")]
fn phase_event_to_json(evt: &ui_automata::PhaseEvent) -> String {
use ui_automata::PhaseEvent;
let v = match evt {
PhaseEvent::PhaseStarted(phase) => {
serde_json::json!({"type": "PhaseStarted", "phase": phase})
}
PhaseEvent::PhaseCompleted(phase) => {
serde_json::json!({"type": "PhaseCompleted", "phase": phase})
}
PhaseEvent::PhaseSkipped(phase) => {
serde_json::json!({"type": "PhaseSkipped", "phase": phase})
}
PhaseEvent::PhaseFailed { phase, error } => {
serde_json::json!({"type": "PhaseFailed", "phase": phase, "error": error})
}
PhaseEvent::Completed | PhaseEvent::Failed(_) => return String::new(),
};
v.to_string()
}
#[cfg(target_os = "windows")]
fn tail_lines(path: &std::path::Path, n: usize) -> String {
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return String::new(),
};
let lines: Vec<&str> = content.lines().collect();
let skip = lines.len().saturating_sub(n);
lines[skip..].join("\n")
}
#[cfg(target_os = "windows")]
fn main() {
use std::collections::HashMap;
use std::io::IsTerminal as _;
use std::path::PathBuf;
use clap::Parser;
#[derive(Parser)]
#[command(name = "ui-workflow")]
struct Cli {
script: PathBuf,
#[arg(long)]
project_dir: Option<PathBuf>,
#[arg(long)]
log_path: Option<PathBuf>,
#[arg(last = true)]
script_args: Vec<String>,
}
automata_windows::init_com();
let cli = Cli::parse();
let mut params: HashMap<String, String> = HashMap::new();
let mut iter = cli.script_args.iter();
while let Some(flag) = iter.next() {
if let Some(key) = flag.strip_prefix("--") {
let snake = key.replace('-', "_");
let value = iter.next().cloned().unwrap_or_default();
params.insert(snake, value);
}
}
let pipe_mode = !std::io::stdout().is_terminal();
if pipe_mode {
unsafe extern "system" {
fn FreeConsole() -> i32;
}
unsafe {
FreeConsole();
}
}
let log_path: PathBuf = if let Some(p) = cli.log_path {
p
} else {
let yaml = match std::fs::read_to_string(&cli.script) {
Ok(s) => s,
Err(e) => {
eprintln!("cannot read {}: {e}", cli.script.display());
std::process::exit(1);
}
};
let project_dir = match cli.project_dir {
Some(d) => d,
None => {
let home = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
.unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".ui-automata").join("logs")
}
};
let script_stem = cli
.script
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let folder_name =
ui_automata::yaml::WorkflowName::read(&yaml).unwrap_or_else(|| script_stem.clone());
let run_dir = project_dir.join(&folder_name);
if let Err(e) = std::fs::create_dir_all(&run_dir) {
log::warn!("could not create run dir {}: {e}", run_dir.display());
}
let timestamp = {
use time::macros::format_description;
let fmt = format_description!("[year][month][day]T[hour][minute][second]");
time::OffsetDateTime::now_local()
.unwrap_or_else(|_| time::OffsetDateTime::now_utc())
.format(fmt)
.unwrap_or_else(|_| "00000000T000000".into())
};
run_dir.join(format!("{timestamp}.log"))
};
if pipe_mode {
automata_windows::init_logging_file_only(&log_path);
} else {
automata_windows::init_logging(Some(&log_path));
log::info!("log → {}", log_path.display());
}
if pipe_mode {
use windows::Win32::System::Console::SetConsoleCtrlHandler;
unsafe {
let _ = SetConsoleCtrlHandler(Some(ctrl_handler), true);
}
}
let script_str = cli.script.to_string_lossy().into_owned();
let workflow = match ui_automata::yaml::WorkflowFile::load(&script_str, ¶ms) {
Ok(wf) => wf,
Err(e) => {
if pipe_mode {
let json = serde_json::json!({"type": "Failed", "error": e.to_string()});
println!("{json}");
} else {
log::error!("failed to load workflow: {e}");
}
std::process::exit(1);
}
};
let desktop = automata_windows::Desktop::new();
let mut executor = ui_automata::Executor::new(desktop);
if pipe_mode {
use std::io::Write as _;
std::thread::spawn(|| {
use std::io::Read as _;
let mut buf = [0u8; 1];
loop {
match std::io::stdin().read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
}
CANCEL.store(true, std::sync::atomic::Ordering::Relaxed);
});
let cancel_flag = Some(&CANCEL as &std::sync::atomic::AtomicBool);
let result = workflow.run(
&mut executor,
Some(&mut |evt| {
let line = phase_event_to_json(&evt);
if !line.is_empty() {
println!("{line}");
let _ = std::io::stdout().flush();
}
}),
cancel_flag,
);
drop(executor);
let terminal_json = match result {
Ok(state) => {
log::info!("outputs: {}", outputs_to_json(state.output));
serde_json::json!({"type": "Completed"}).to_string()
}
Err(e) => {
let tail = tail_lines(&log_path, 100);
let error = if tail.is_empty() {
e.to_string()
} else {
format!(
"{e}\n\n--- last {n} lines of trace ---\n{tail}",
n = tail.lines().count()
)
};
serde_json::json!({"type": "Failed", "error": error}).to_string()
}
};
println!("{terminal_json}");
let _ = std::io::stdout().flush();
} else {
match workflow.run(&mut executor, None, None) {
Ok(state) => {
log::info!("outputs: {}", outputs_to_json(state.output));
log::info!("completed successfully");
}
Err(e) => {
log::error!("workflow failed: {e}");
std::process::exit(1);
}
}
}
}
fn outputs_to_json(output: ui_automata::Output) -> serde_json::Value {
let map: serde_json::Map<String, serde_json::Value> = output
.into_map()
.into_iter()
.map(|(k, v)| {
let val = if v.len() == 1 {
serde_json::Value::String(v.into_iter().next().unwrap())
} else {
serde_json::Value::Array(v.into_iter().map(serde_json::Value::String).collect())
};
(k, val)
})
.collect();
serde_json::Value::Object(map)
}