nils-macos-agent 0.7.3

CLI crate for nils-macos-agent in the nils-cli workspace.
Documentation
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};

use crate::backend::AutoAxBackend;
use crate::backend::process::RealProcessRunner;
use crate::cli::{ObserveScreenshotArgs, OutputFormat};
use crate::commands::ax_common::{
    build_selector_from_args, build_target, resolve_selector_node_against_backend,
    selector_args_requested,
};
use crate::commands::{emit_json_success, reject_tsv_for_list_only};
use crate::error::CliError;
use crate::model::{
    AxFrame, IfChangedResult, ScreenshotResult, ScreenshotSelectorResult, WindowRow,
};
use crate::targets::{self, TargetSelector};
use crate::test_mode;

pub fn run_screenshot(format: OutputFormat, args: &ObserveScreenshotArgs) -> Result<(), CliError> {
    let selector = TargetSelector {
        window_id: args.window_id,
        active_window: args.active_window,
        app: args.app.clone(),
        window_name: args.window_name.clone(),
    };

    let window = targets::resolve_window(&selector)?;
    let output_path = resolve_output_path(args, window.id);
    let image_format = args
        .image_format
        .or_else(|| targets::extension_format(&output_path))
        .unwrap_or(crate::cli::ImageFormat::Png);

    let (selector, if_changed) = if args.if_changed {
        let (selector, result) = capture_if_changed(args, &window, image_format, &output_path)?;
        (selector, Some(result))
    } else {
        let selector = capture_to_path(args, &window, image_format, &output_path)?;
        (selector, None)
    };

    match format {
        OutputFormat::Json => {
            let result = ScreenshotResult {
                path: output_path.display().to_string(),
                target: WindowRow::from(&window),
                selector,
                if_changed,
            };
            emit_json_success("observe.screenshot", result)?;
        }
        OutputFormat::Text => {
            println!("{}", output_path.display());
        }
        OutputFormat::Tsv => {
            return reject_tsv_for_list_only();
        }
    }

    Ok(())
}

fn capture_to_path(
    args: &ObserveScreenshotArgs,
    window: &crate::screen_record_adapter::WindowInfo,
    image_format: crate::cli::ImageFormat,
    capture_path: &Path,
) -> Result<Option<ScreenshotSelectorResult>, CliError> {
    if selector_args_requested(&args.ax_selector) {
        let selector = build_selector_from_args(&args.ax_selector)?;
        let ax_target = build_target(
            None,
            args.app.clone().or_else(|| Some(window.owner_name.clone())),
            None,
            args.window_name.clone().or_else(|| {
                if window.title.trim().is_empty() {
                    None
                } else {
                    Some(window.title.clone())
                }
            }),
        )?;
        let backend = AutoAxBackend::default();
        let runner = RealProcessRunner;
        let (evaluation, selected_node) =
            resolve_selector_node_against_backend(&runner, &backend, &ax_target, &selector, 4_000)
                .map_err(|err| {
                    err.with_operation("observe.screenshot").with_hint(
                        "Refine AX selector or validate target window context before retrying.",
                    )
                })?;
        let frame = selected_node.frame.ok_or_else(|| {
            CliError::runtime("selected AX node does not expose frame metadata")
                .with_operation("observe.screenshot")
                .with_hint("Choose an AX element that reports frame bounds.")
        })?;
        let capture_region = padded_region(&frame, args.selector_padding, window)?;
        targets::capture_screenshot_region(capture_path, window, image_format, &capture_region)?;
        Ok(Some(ScreenshotSelectorResult {
            node_id: selected_node.node_id,
            matched_count: evaluation.matched_count,
            padding: args.selector_padding,
            frame,
            capture_region,
        }))
    } else {
        targets::capture_screenshot(capture_path, window, image_format)?;
        Ok(None)
    }
}

fn capture_if_changed(
    args: &ObserveScreenshotArgs,
    window: &crate::screen_record_adapter::WindowInfo,
    image_format: crate::cli::ImageFormat,
    output_path: &Path,
) -> Result<(Option<ScreenshotSelectorResult>, IfChangedResult), CliError> {
    let threshold = args.if_changed_threshold.unwrap_or(0);
    let baseline_path = resolve_baseline_path(args, output_path)?;
    let baseline_hash = baseline_path
        .as_ref()
        .map(|path| hash_file_u64(path))
        .transpose()?;

    let staged_path = staged_if_changed_path(output_path)?;
    let selector = match capture_to_path(args, window, image_format, &staged_path) {
        Ok(selector) => selector,
        Err(err) => {
            let _ = std::fs::remove_file(&staged_path);
            return Err(err);
        }
    };
    let current_hash = match hash_file_u64(&staged_path) {
        Ok(value) => value,
        Err(err) => {
            let _ = std::fs::remove_file(&staged_path);
            return Err(err);
        }
    };

    let changed_by_threshold = baseline_hash
        .map(|baseline| hamming_distance(baseline, current_hash) > threshold)
        .unwrap_or(true);
    let changed = changed_by_threshold || !output_path.exists();
    let captured_path = if changed {
        publish_if_changed_capture(&staged_path, output_path)?;
        Some(output_path.display().to_string())
    } else {
        let _ = std::fs::remove_file(&staged_path);
        None
    };

    Ok((
        selector,
        IfChangedResult {
            changed,
            baseline_hash: baseline_hash.map(hash_hex),
            current_hash: hash_hex(current_hash),
            threshold,
            captured_path,
        },
    ))
}

fn resolve_baseline_path(
    args: &ObserveScreenshotArgs,
    output_path: &Path,
) -> Result<Option<PathBuf>, CliError> {
    if let Some(path) = args.if_changed_baseline.as_ref() {
        if !path.exists() {
            return Err(CliError::runtime(format!(
                "--if-changed-baseline path does not exist: {}",
                path.display()
            )));
        }
        return Ok(Some(path.clone()));
    }

    if output_path.exists() {
        return Ok(Some(output_path.to_path_buf()));
    }

    Ok(None)
}

fn staged_if_changed_path(output_path: &Path) -> Result<PathBuf, CliError> {
    let parent = output_path
        .parent()
        .ok_or_else(|| CliError::runtime("missing output parent dir"))?;
    let name = output_path
        .file_name()
        .and_then(|value| value.to_str())
        .unwrap_or("screenshot");
    let pid = std::process::id();
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|duration| duration.as_nanos())
        .unwrap_or(0);
    Ok(parent.join(format!(".{name}.ifchanged-{pid}-{nanos}")))
}

fn publish_if_changed_capture(staged_path: &Path, output_path: &Path) -> Result<(), CliError> {
    if output_path.exists() {
        std::fs::remove_file(output_path).map_err(|err| {
            CliError::runtime(format!("failed to replace output screenshot file: {err}"))
        })?;
    }
    std::fs::rename(staged_path, output_path)
        .map_err(|err| CliError::runtime(format!("failed to write output screenshot: {err}")))
}

fn hash_file_u64(path: &Path) -> Result<u64, CliError> {
    let bytes = std::fs::read(path).map_err(|err| {
        CliError::runtime(format!(
            "failed to read image for --if-changed hash: {} ({err})",
            path.display()
        ))
    })?;
    let mut hasher = DefaultHasher::new();
    bytes.hash(&mut hasher);
    Ok(hasher.finish())
}

fn hash_hex(value: u64) -> String {
    format!("{value:016x}")
}

fn hamming_distance(a: u64, b: u64) -> u32 {
    (a ^ b).count_ones()
}

fn resolve_output_path(args: &ObserveScreenshotArgs, window_id: u32) -> PathBuf {
    args.path.clone().unwrap_or_else(|| {
        let token = test_mode::timestamp_token();
        PathBuf::from(format!("macos-agent-{token}-window-{window_id}.png"))
    })
}

fn padded_region(
    frame: &AxFrame,
    padding: i32,
    window: &crate::screen_record_adapter::WindowInfo,
) -> Result<AxFrame, CliError> {
    let padding = padding.max(0) as f64;
    let window_left = window.bounds.x as f64;
    let window_top = window.bounds.y as f64;
    let window_right = window_left + window.bounds.width.max(1) as f64;
    let window_bottom = window_top + window.bounds.height.max(1) as f64;

    let left = (frame.x - padding).max(window_left);
    let top = (frame.y - padding).max(window_top);
    let right = (frame.x + frame.width + padding).min(window_right);
    let bottom = (frame.y + frame.height + padding).min(window_bottom);

    if right <= left || bottom <= top {
        return Err(CliError::runtime(
            "selector frame collapsed after applying padding/window bounds",
        )
        .with_operation("observe.screenshot")
        .with_hint("Reduce --selector-padding or pick a different selector."));
    }

    Ok(AxFrame {
        x: left,
        y: top,
        width: right - left,
        height: bottom - top,
    })
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::{padded_region, resolve_output_path};
    use crate::cli::ObserveScreenshotArgs;
    use crate::model::AxFrame;
    use crate::screen_record_adapter::WindowInfo;
    use screen_record::types::Rect;

    #[test]
    fn preserve_explicit_output_path() {
        let args = ObserveScreenshotArgs {
            window_id: Some(1),
            active_window: false,
            app: None,
            window_name: None,
            path: Some(PathBuf::from("./out/image.png")),
            image_format: None,
            ax_selector: crate::cli::AxSelectorArgs::default(),
            selector_padding: 0,
            if_changed: false,
            if_changed_baseline: None,
            if_changed_threshold: None,
        };

        assert_eq!(
            resolve_output_path(&args, 123),
            PathBuf::from("./out/image.png")
        );
    }

    #[test]
    fn padded_region_clamps_to_window_bounds() {
        let frame = AxFrame {
            x: 100.0,
            y: 60.0,
            width: 40.0,
            height: 20.0,
        };
        let window = WindowInfo {
            id: 1,
            owner_name: "Terminal".to_string(),
            title: "Main".to_string(),
            bounds: Rect {
                x: 90,
                y: 50,
                width: 45,
                height: 25,
            },
            on_screen: true,
            active: true,
            owner_pid: 1,
            z_order: 0,
        };

        let region = padded_region(&frame, 20, &window).expect("region");
        assert_eq!(
            region,
            AxFrame {
                x: 90.0,
                y: 50.0,
                width: 45.0,
                height: 25.0,
            }
        );
    }

    #[test]
    fn padded_region_errors_when_result_collapses() {
        let frame = AxFrame {
            x: 10.0,
            y: 10.0,
            width: 0.0,
            height: 0.0,
        };
        let window = WindowInfo {
            id: 2,
            owner_name: "Terminal".to_string(),
            title: "Main".to_string(),
            bounds: Rect {
                x: 100,
                y: 100,
                width: 10,
                height: 10,
            },
            on_screen: true,
            active: true,
            owner_pid: 1,
            z_order: 0,
        };

        let err = padded_region(&frame, 0, &window).expect_err("expected collapsed region");
        assert!(
            err.message()
                .contains("selector frame collapsed after applying padding/window bounds")
        );
    }
}