nils-macos-agent 0.6.2

CLI crate for nils-macos-agent in the nils-cli workspace.
Documentation
use std::process::Command;
use std::time::Instant;

use serde::Deserialize;
use serde_json::Value;

use crate::cli::{OutputFormat, ScenarioRunArgs};
use crate::commands::{emit_json_success, reject_tsv_for_list_only};
use crate::error::CliError;
use crate::model::{ScenarioRunResult, ScenarioStepResult};

#[derive(Debug, Deserialize)]
struct ScenarioFile {
    steps: Vec<ScenarioStepSpec>,
}

#[derive(Debug, Deserialize)]
struct ScenarioStepSpec {
    #[serde(default)]
    id: Option<String>,
    args: Vec<String>,
}

pub fn run(format: OutputFormat, args: &ScenarioRunArgs) -> Result<(), CliError> {
    let raw = std::fs::read_to_string(&args.file).map_err(|err| {
        CliError::runtime(format!(
            "failed to read scenario file `{}`: {err}",
            args.file.display()
        ))
        .with_operation("scenario.run")
    })?;
    let scenario: ScenarioFile = serde_json::from_str(&raw).map_err(|err| {
        CliError::usage(format!(
            "scenario file `{}` is not valid json: {err}",
            args.file.display()
        ))
        .with_operation("scenario.run")
    })?;

    if scenario.steps.is_empty() {
        return Err(
            CliError::usage("scenario file must contain at least one step")
                .with_operation("scenario.run"),
        );
    }

    let exe = std::env::current_exe().map_err(|err| {
        CliError::runtime(format!(
            "failed to resolve current executable for scenario run: {err}"
        ))
        .with_operation("scenario.run")
    })?;

    let mut step_results = Vec::with_capacity(scenario.steps.len());
    let mut first_failed_step_id = None;

    for (idx, step) in scenario.steps.iter().enumerate() {
        if step.args.is_empty() {
            return Err(
                CliError::usage(format!("scenario step {} has empty args", idx + 1))
                    .with_operation("scenario.run"),
            );
        }
        if step.args.iter().any(|arg| arg == "scenario") {
            return Err(CliError::usage(format!(
                "scenario step {} recursively invokes `scenario`; this is not allowed",
                idx + 1
            ))
            .with_operation("scenario.run")
            .with_hint("Call primitive commands directly from step args."));
        }

        let step_id = step
            .id
            .clone()
            .unwrap_or_else(|| format!("step-{}", idx + 1));

        let started = Instant::now();
        let output = Command::new(&exe)
            .args(&step.args)
            .output()
            .map_err(|err| {
                CliError::runtime(format!(
                    "failed to execute scenario step `{step_id}`: {err}"
                ))
                .with_operation("scenario.run")
            })?;

        let exit_code = output.status.code().unwrap_or(-1);
        let ok = output.status.success();
        let stdout_text = String::from_utf8_lossy(&output.stdout).trim().to_string();
        let stderr_text = String::from_utf8_lossy(&output.stderr).trim().to_string();
        let (operation, ax_path, fallback_used) = extract_step_telemetry(&stdout_text);
        let step_result = ScenarioStepResult {
            step_id: step_id.clone(),
            ok,
            exit_code,
            elapsed_ms: started.elapsed().as_millis() as u64,
            operation,
            ax_path,
            fallback_used,
            stdout: stdout_text,
            stderr: stderr_text,
        };

        step_results.push(step_result);
        if !ok {
            first_failed_step_id = Some(step_id);
            break;
        }
    }

    let failed_steps = step_results.iter().filter(|step| !step.ok).count();
    let passed_steps = step_results.iter().filter(|step| step.ok).count();
    let result = ScenarioRunResult {
        file: args.file.display().to_string(),
        total_steps: scenario.steps.len(),
        passed_steps,
        failed_steps,
        first_failed_step_id: first_failed_step_id.clone(),
        steps: step_results,
    };

    if failed_steps > 0 {
        let failed = first_failed_step_id.unwrap_or_else(|| "<unknown>".to_string());
        return Err(CliError::runtime(format!(
            "scenario run failed at `{failed}`"
        ))
        .with_operation("scenario.run")
        .with_hint("Inspect step stderr in scenario output and rerun with --trace for richer diagnostics."));
    }

    match format {
        OutputFormat::Json => {
            emit_json_success("scenario.run", result)?;
        }
        OutputFormat::Text => {
            println!(
                "scenario.run\tfile={}\ttotal_steps={}\tpassed_steps={}\tfailed_steps={}",
                result.file, result.total_steps, result.passed_steps, result.failed_steps
            );
        }
        OutputFormat::Tsv => {
            return reject_tsv_for_list_only();
        }
    }

    Ok(())
}

fn extract_step_telemetry(stdout: &str) -> (Option<String>, Option<String>, Option<bool>) {
    let payload: Value = match serde_json::from_str(stdout) {
        Ok(value) => value,
        Err(_) => return (None, None, None),
    };
    let command = payload
        .get("command")
        .and_then(Value::as_str)
        .map(str::to_string);
    let operation = command.clone();

    match command.as_deref() {
        Some("ax.click") => {
            let fallback = payload
                .pointer("/result/used_coordinate_fallback")
                .and_then(Value::as_bool);
            let ax_path = fallback.map(|value| {
                if value {
                    "coordinate-fallback".to_string()
                } else {
                    "ax-native".to_string()
                }
            });
            (operation, ax_path, fallback)
        }
        Some("ax.type") => {
            let fallback = payload
                .pointer("/result/used_keyboard_fallback")
                .and_then(Value::as_bool);
            let ax_path = fallback.map(|value| {
                if value {
                    "keyboard-fallback".to_string()
                } else {
                    "ax-native".to_string()
                }
            });
            (operation, ax_path, fallback)
        }
        _ => (operation, None, None),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_scenario_file() {
        let raw = r#"{"steps":[{"id":"s1","args":["preflight","--format","json"]}]}"#;
        let parsed: ScenarioFile = serde_json::from_str(raw).expect("scenario json should parse");
        assert_eq!(parsed.steps.len(), 1);
        assert_eq!(parsed.steps[0].id.as_deref(), Some("s1"));
    }

    #[test]
    fn extract_step_telemetry_maps_ax_fallback_modes() {
        let click_payload = r#"{"command":"ax.click","result":{"used_coordinate_fallback":true}}"#;
        let (operation, ax_path, fallback) = extract_step_telemetry(click_payload);
        assert_eq!(operation.as_deref(), Some("ax.click"));
        assert_eq!(ax_path.as_deref(), Some("coordinate-fallback"));
        assert_eq!(fallback, Some(true));

        let type_payload = r#"{"command":"ax.type","result":{"used_keyboard_fallback":false}}"#;
        let (operation, ax_path, fallback) = extract_step_telemetry(type_payload);
        assert_eq!(operation.as_deref(), Some("ax.type"));
        assert_eq!(ax_path.as_deref(), Some("ax-native"));
        assert_eq!(fallback, Some(false));
    }
}