Skip to main content

macos_agent/commands/
ax_common.rs

1use std::time::Instant;
2
3use regex::{Regex, RegexBuilder};
4use serde_json::Value;
5
6use crate::backend::AxBackendAdapter;
7use crate::backend::applescript;
8use crate::backend::process::ProcessRunner;
9use crate::cli::{AxSelectorArgs, AxTargetArgs};
10use crate::error::CliError;
11use crate::model::{
12    AxAttrGetRequest, AxGateCheckResult, AxGateResult, AxListRequest, AxMatchStrategy, AxNode,
13    AxPostconditionCheckResult, AxPostconditionResult, AxSelector, AxSelectorExplain,
14    AxSelectorExplainStage, AxTarget,
15};
16use crate::targets::{self, TargetSelector};
17use crate::wait;
18
19#[derive(Debug, Clone, Default)]
20pub struct AxSelectorInput {
21    pub node_id: Option<String>,
22    pub role: Option<String>,
23    pub title_contains: Option<String>,
24    pub identifier_contains: Option<String>,
25    pub value_contains: Option<String>,
26    pub subrole: Option<String>,
27    pub focused: Option<bool>,
28    pub enabled: Option<bool>,
29    pub nth: Option<u32>,
30    pub match_strategy: AxMatchStrategy,
31    pub explain: bool,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum SelectorSelectionStatus {
36    Selected,
37    NoMatches,
38    NthOutOfRange,
39    Ambiguous,
40}
41
42#[derive(Debug, Clone)]
43pub struct SelectorEvaluation {
44    pub matched_count: usize,
45    pub selected_node_id: Option<String>,
46    pub selection_status: SelectorSelectionStatus,
47    pub explain: Option<AxSelectorExplain>,
48}
49
50#[derive(Debug, Clone, Copy, Default)]
51pub struct AxActionGateOptions {
52    pub app_active: bool,
53    pub window_present: bool,
54    pub ax_present: bool,
55    pub ax_unique: bool,
56    pub timeout_ms: u64,
57    pub poll_ms: u64,
58}
59
60impl AxActionGateOptions {
61    pub fn any_enabled(self) -> bool {
62        self.app_active || self.window_present || self.ax_present || self.ax_unique
63    }
64}
65
66#[derive(Debug, Clone, PartialEq)]
67pub enum AxPostconditionCheck {
68    Focused(bool),
69    AttributeValue { name: String, expected: Value },
70}
71
72impl AxPostconditionCheck {
73    fn name(&self) -> String {
74        match self {
75            Self::Focused(expected) => format!("focused={expected}"),
76            Self::AttributeValue { name, .. } => format!("attribute={name}"),
77        }
78    }
79
80    fn expected_value(&self) -> Value {
81        match self {
82            Self::Focused(expected) => Value::Bool(*expected),
83            Self::AttributeValue { expected, .. } => expected.clone(),
84        }
85    }
86
87    fn attribute_name(&self) -> Option<String> {
88        match self {
89            Self::Focused(_) => None,
90            Self::AttributeValue { name, .. } => Some(name.clone()),
91        }
92    }
93}
94
95#[derive(Debug, Clone, Default)]
96pub struct AxPostconditionOptions {
97    pub checks: Vec<AxPostconditionCheck>,
98    pub timeout_ms: u64,
99    pub poll_ms: u64,
100}
101
102impl AxPostconditionOptions {
103    pub fn any_enabled(&self) -> bool {
104        !self.checks.is_empty()
105    }
106}
107
108pub fn build_target(
109    session_id: Option<String>,
110    app: Option<String>,
111    bundle_id: Option<String>,
112    window_title_contains: Option<String>,
113) -> Result<AxTarget, CliError> {
114    let mut target_count = 0;
115    if session_id.is_some() {
116        target_count += 1;
117    }
118    if app.is_some() {
119        target_count += 1;
120    }
121    if bundle_id.is_some() {
122        target_count += 1;
123    }
124
125    if target_count > 1 {
126        return Err(CliError::usage(
127            "--session-id cannot be combined with --app/--bundle-id",
128        ));
129    }
130
131    Ok(AxTarget {
132        session_id,
133        app,
134        bundle_id,
135        window_title_contains,
136    })
137}
138
139pub fn build_target_from_args(args: &AxTargetArgs) -> Result<AxTarget, CliError> {
140    build_target(
141        args.session_id.clone(),
142        args.app.clone(),
143        args.bundle_id.clone(),
144        args.window_title_contains.clone(),
145    )
146}
147
148pub fn selector_input_from_args(args: &AxSelectorArgs) -> AxSelectorInput {
149    AxSelectorInput {
150        node_id: args.node_id.clone(),
151        role: args.filters.role.clone(),
152        title_contains: args.filters.title_contains.clone(),
153        identifier_contains: args.filters.identifier_contains.clone(),
154        value_contains: args.filters.value_contains.clone(),
155        subrole: args.filters.subrole.clone(),
156        focused: args.filters.focused,
157        enabled: args.filters.enabled,
158        nth: args.nth,
159        match_strategy: args.match_strategy,
160        explain: args.selector_explain,
161    }
162}
163
164pub fn build_selector(input: AxSelectorInput) -> Result<AxSelector, CliError> {
165    if input.nth == Some(0) {
166        return Err(CliError::usage("--nth must be at least 1"));
167    }
168
169    let has_primary_filters = input.role.is_some()
170        || input.title_contains.is_some()
171        || input.identifier_contains.is_some()
172        || input.value_contains.is_some()
173        || input.subrole.is_some()
174        || input.focused.is_some()
175        || input.enabled.is_some();
176    let has_non_node_filters = has_primary_filters || input.nth.is_some();
177
178    if input.node_id.is_some() && has_non_node_filters {
179        return Err(CliError::usage(
180            "--node-id cannot be combined with role/title/identifier/value/subrole/focused/enabled/nth selectors",
181        ));
182    }
183
184    if input.node_id.is_none() && !has_primary_filters {
185        if input.nth.is_some() {
186            return Err(CliError::usage(
187                "--nth requires at least one selector filter when --node-id is not set",
188            ));
189        }
190        return Err(CliError::usage(
191            "provide --node-id or at least one selector filter (--role/--title-contains/--identifier-contains/--value-contains/--subrole/--focused/--enabled)",
192        ));
193    }
194
195    if input.match_strategy == AxMatchStrategy::Regex {
196        validate_selector_regex("--title-contains", input.title_contains.as_deref())?;
197        validate_selector_regex(
198            "--identifier-contains",
199            input.identifier_contains.as_deref(),
200        )?;
201        validate_selector_regex("--value-contains", input.value_contains.as_deref())?;
202    }
203
204    Ok(AxSelector {
205        node_id: input.node_id,
206        role: input.role,
207        title_contains: input.title_contains,
208        identifier_contains: input.identifier_contains,
209        value_contains: input.value_contains,
210        subrole: input.subrole,
211        focused: input.focused,
212        enabled: input.enabled,
213        nth: input.nth.map(|value| value as usize),
214        match_strategy: input.match_strategy,
215        explain: input.explain,
216    })
217}
218
219pub fn build_selector_from_args(args: &AxSelectorArgs) -> Result<AxSelector, CliError> {
220    build_selector(selector_input_from_args(args))
221}
222
223pub fn selector_selection_error(
224    operation: &str,
225    status: SelectorSelectionStatus,
226) -> Option<CliError> {
227    let error = match status {
228        SelectorSelectionStatus::Selected => return None,
229        SelectorSelectionStatus::NoMatches => {
230            CliError::runtime("selector returned zero AX matches")
231        }
232        SelectorSelectionStatus::NthOutOfRange => CliError::runtime("selector nth is out of range"),
233        SelectorSelectionStatus::Ambiguous => {
234            CliError::runtime("selector is ambiguous; add --nth or narrow selector filters")
235        }
236    };
237
238    Some(
239        error
240            .with_operation(operation)
241            .with_hint("Adjust AX selector filters so exactly one element is targeted."),
242    )
243}
244
245pub fn evaluate_selector_against_backend(
246    runner: &dyn ProcessRunner,
247    backend: &dyn AxBackendAdapter,
248    target: &AxTarget,
249    selector: &AxSelector,
250    timeout_ms: u64,
251) -> Result<SelectorEvaluation, CliError> {
252    let list_result = backend.list(
253        runner,
254        &AxListRequest {
255            target: target.clone(),
256            ..AxListRequest::default()
257        },
258        timeout_ms.max(1),
259    )?;
260    evaluate_selector_against_nodes(&list_result.nodes, selector)
261}
262
263pub fn resolve_selector_node_against_backend(
264    runner: &dyn ProcessRunner,
265    backend: &dyn AxBackendAdapter,
266    target: &AxTarget,
267    selector: &AxSelector,
268    timeout_ms: u64,
269) -> Result<(SelectorEvaluation, AxNode), CliError> {
270    let list_result = backend.list(
271        runner,
272        &AxListRequest {
273            target: target.clone(),
274            ..AxListRequest::default()
275        },
276        timeout_ms.max(1),
277    )?;
278    let evaluation = evaluate_selector_against_nodes(&list_result.nodes, selector)?;
279    if let Some(error) = selector_selection_error("selector.resolve", evaluation.selection_status) {
280        return Err(error);
281    }
282
283    let selected_node_id = evaluation
284        .selected_node_id
285        .as_ref()
286        .ok_or_else(|| CliError::runtime("selector evaluation returned no node"))?;
287    let node = list_result
288        .nodes
289        .into_iter()
290        .find(|candidate| candidate.node_id == *selected_node_id)
291        .ok_or_else(|| {
292            CliError::runtime(format!(
293                "selector resolved to `{selected_node_id}` but node details were unavailable"
294            ))
295        })?;
296
297    Ok((evaluation, node))
298}
299
300pub fn selector_args_requested(args: &AxSelectorArgs) -> bool {
301    args.node_id.is_some()
302        || args.filters.role.is_some()
303        || args.filters.title_contains.is_some()
304        || args.filters.identifier_contains.is_some()
305        || args.filters.value_contains.is_some()
306        || args.filters.subrole.is_some()
307        || args.filters.focused.is_some()
308        || args.filters.enabled.is_some()
309        || args.nth.is_some()
310}
311
312pub fn parse_postcondition_expected_value(raw: &str) -> Value {
313    serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
314}
315
316pub fn evaluate_selector_against_nodes(
317    nodes: &[AxNode],
318    selector: &AxSelector,
319) -> Result<SelectorEvaluation, CliError> {
320    let mut current = nodes.iter().collect::<Vec<_>>();
321    let mut stage_results = Vec::new();
322
323    if let Some(node_id) = selector.node_id.as_deref() {
324        apply_stage("node_id", &mut current, &mut stage_results, |node| {
325            node.node_id == node_id
326        });
327    } else {
328        if let Some(role_filter) = selector.role.as_deref() {
329            apply_stage("role", &mut current, &mut stage_results, |node| {
330                node.role.eq_ignore_ascii_case(role_filter)
331            });
332        }
333
334        if let Some(filter) = selector.title_contains.as_deref() {
335            let matcher = build_text_matcher(filter, selector.match_strategy).map_err(|err| {
336                err.with_hint(
337                    "Use a valid pattern for --title-contains under --match-strategy regex.",
338                )
339            })?;
340            apply_stage("title", &mut current, &mut stage_results, |node| {
341                matcher.matches(node.title.as_deref().unwrap_or_default())
342                    || matcher.matches(node.identifier.as_deref().unwrap_or_default())
343            });
344        }
345
346        if let Some(filter) = selector.identifier_contains.as_deref() {
347            let matcher = build_text_matcher(filter, selector.match_strategy).map_err(|err| {
348                err.with_hint(
349                    "Use a valid pattern for --identifier-contains under --match-strategy regex.",
350                )
351            })?;
352            apply_stage("identifier", &mut current, &mut stage_results, |node| {
353                matcher.matches(node.identifier.as_deref().unwrap_or_default())
354            });
355        }
356
357        if let Some(filter) = selector.value_contains.as_deref() {
358            let matcher = build_text_matcher(filter, selector.match_strategy).map_err(|err| {
359                err.with_hint(
360                    "Use a valid pattern for --value-contains under --match-strategy regex.",
361                )
362            })?;
363            apply_stage("value", &mut current, &mut stage_results, |node| {
364                matcher.matches(node.value_preview.as_deref().unwrap_or_default())
365            });
366        }
367
368        if let Some(subrole_filter) = selector.subrole.as_deref() {
369            apply_stage("subrole", &mut current, &mut stage_results, |node| {
370                node.subrole
371                    .as_deref()
372                    .unwrap_or_default()
373                    .eq_ignore_ascii_case(subrole_filter)
374            });
375        }
376
377        if let Some(focused_filter) = selector.focused {
378            apply_stage("focused", &mut current, &mut stage_results, |node| {
379                node.focused == focused_filter
380            });
381        }
382
383        if let Some(enabled_filter) = selector.enabled {
384            apply_stage("enabled", &mut current, &mut stage_results, |node| {
385                node.enabled == enabled_filter
386            });
387        }
388    }
389
390    let matched_count = current.len();
391    let mut selected_node_id = None;
392    let selection_status = if selector.node_id.is_some() {
393        if matched_count == 0 {
394            SelectorSelectionStatus::NoMatches
395        } else {
396            selected_node_id = current.first().map(|node| node.node_id.clone());
397            SelectorSelectionStatus::Selected
398        }
399    } else if let Some(nth) = selector.nth {
400        let before_count = matched_count;
401        if nth >= 1 && nth <= matched_count {
402            selected_node_id = current.get(nth - 1).map(|node| node.node_id.clone());
403            stage_results.push(AxSelectorExplainStage {
404                stage: "nth".to_string(),
405                before_count,
406                after_count: 1,
407            });
408            SelectorSelectionStatus::Selected
409        } else {
410            stage_results.push(AxSelectorExplainStage {
411                stage: "nth".to_string(),
412                before_count,
413                after_count: 0,
414            });
415            SelectorSelectionStatus::NthOutOfRange
416        }
417    } else if matched_count == 0 {
418        SelectorSelectionStatus::NoMatches
419    } else if matched_count == 1 {
420        selected_node_id = current.first().map(|node| node.node_id.clone());
421        SelectorSelectionStatus::Selected
422    } else {
423        SelectorSelectionStatus::Ambiguous
424    };
425
426    let explain = if selector.explain {
427        Some(AxSelectorExplain {
428            strategy: selector.match_strategy,
429            total_candidates: nodes.len(),
430            matched_count,
431            selected_count: if selected_node_id.is_some() { 1 } else { 0 },
432            stage_results,
433            selected_node_id: selected_node_id.clone(),
434        })
435    } else {
436        None
437    };
438
439    Ok(SelectorEvaluation {
440        matched_count,
441        selected_node_id,
442        selection_status,
443        explain,
444    })
445}
446
447pub fn run_action_gates(
448    operation: &str,
449    runner: &dyn ProcessRunner,
450    backend: &dyn AxBackendAdapter,
451    target: &AxTarget,
452    selector: &AxSelector,
453    options: AxActionGateOptions,
454    backend_timeout_ms: u64,
455) -> Result<Option<AxGateResult>, CliError> {
456    if !options.any_enabled() {
457        return Ok(None);
458    }
459
460    let policy = wait::WaitPolicy::new(options.timeout_ms, options.poll_ms);
461    let mut checks = Vec::new();
462
463    if options.app_active {
464        checks.push(run_gate_app_active(operation, runner, target, policy)?);
465    }
466    if options.window_present {
467        checks.push(run_gate_window_present(operation, target, policy)?);
468    }
469    if options.ax_present {
470        checks.push(run_gate_ax_selector(
471            operation,
472            "ax-present",
473            runner,
474            backend,
475            target,
476            selector,
477            policy,
478            backend_timeout_ms,
479            |matched| matched >= 1,
480        )?);
481    }
482    if options.ax_unique {
483        checks.push(run_gate_ax_selector(
484            operation,
485            "ax-unique",
486            runner,
487            backend,
488            target,
489            selector,
490            policy,
491            backend_timeout_ms,
492            |matched| matched == 1,
493        )?);
494    }
495
496    Ok(Some(AxGateResult {
497        timeout_ms: policy.timeout_ms,
498        poll_ms: policy.poll_ms,
499        checks,
500    }))
501}
502
503pub fn run_postconditions(
504    operation: &str,
505    runner: &dyn ProcessRunner,
506    backend: &dyn AxBackendAdapter,
507    target: &AxTarget,
508    node_id: &str,
509    options: &AxPostconditionOptions,
510    backend_timeout_ms: u64,
511) -> Result<Option<AxPostconditionResult>, CliError> {
512    if !options.any_enabled() {
513        return Ok(None);
514    }
515
516    let policy = wait::WaitPolicy::new(options.timeout_ms, options.poll_ms);
517    let mut results = Vec::new();
518
519    for check in &options.checks {
520        let started = Instant::now();
521        let mut observed = None;
522        let outcome = wait::wait_until(
523            &format!("{operation}.postcondition.{}", check.name()),
524            policy.timeout_ms,
525            policy.poll_ms,
526            || {
527                let (satisfied, current) = evaluate_postcondition_check(
528                    runner,
529                    backend,
530                    target,
531                    node_id,
532                    check,
533                    backend_timeout_ms,
534                )?;
535                observed = current;
536                Ok(satisfied)
537            },
538        )
539        .map_err(|error| {
540            map_postcondition_error(operation, check, policy.timeout_ms, observed.clone(), error)
541        })?;
542
543        results.push(AxPostconditionCheckResult {
544            check: check.name(),
545            terminal_status: "satisfied".to_string(),
546            attempts: outcome.attempts,
547            elapsed_ms: started.elapsed().as_millis() as u64,
548            attribute: check.attribute_name(),
549            expected: check.expected_value(),
550            observed,
551        });
552    }
553
554    Ok(Some(AxPostconditionResult {
555        timeout_ms: policy.timeout_ms,
556        poll_ms: policy.poll_ms,
557        checks: results,
558    }))
559}
560
561fn run_gate_app_active(
562    operation: &str,
563    runner: &dyn ProcessRunner,
564    target: &AxTarget,
565    policy: wait::WaitPolicy,
566) -> Result<AxGateCheckResult, CliError> {
567    let mut check: Box<dyn FnMut() -> Result<bool, CliError>> =
568        if let Some(app) = target.app.as_deref() {
569            let app = app.to_string();
570            Box::new(move || {
571                let probe_timeout = policy.timeout_ms.max(2_000);
572                applescript::frontmost_app_name(runner, probe_timeout)
573                    .map(|frontmost| frontmost.eq_ignore_ascii_case(&app))
574            })
575        } else if let Some(bundle_id) = target.bundle_id.as_deref() {
576            let bundle_id = bundle_id.to_string();
577            Box::new(move || {
578                let probe_timeout = policy.timeout_ms.max(2_000);
579                applescript::frontmost_bundle_id(runner, probe_timeout)
580                    .map(|frontmost| frontmost.eq_ignore_ascii_case(&bundle_id))
581            })
582        } else {
583            return Err(CliError::usage(
584                "`--gate-app-active` requires target app context (--app or --bundle-id)",
585            )
586            .with_operation(format!("{operation}.gate.app-active"))
587            .with_hint("Provide --app or --bundle-id when enabling app-active gating."));
588        };
589
590    let started = Instant::now();
591    let outcome = wait::wait_until_with_policy("gate.app-active", policy, &mut check)
592        .map_err(|error| map_gate_error(operation, "app-active", policy.timeout_ms, None, error))?;
593    Ok(AxGateCheckResult {
594        gate: "app-active".to_string(),
595        terminal_status: "satisfied".to_string(),
596        attempts: outcome.attempts,
597        elapsed_ms: started.elapsed().as_millis() as u64,
598        matched_count: None,
599    })
600}
601
602fn run_gate_window_present(
603    operation: &str,
604    target: &AxTarget,
605    policy: wait::WaitPolicy,
606) -> Result<AxGateCheckResult, CliError> {
607    if target.session_id.is_some() && target.app.is_none() && target.bundle_id.is_none() {
608        return Err(CliError::usage(
609            "`--gate-window-present` cannot infer app/window from --session-id target alone",
610        )
611        .with_operation(format!("{operation}.gate.window-present"))
612        .with_hint("Add --app or --bundle-id to run window-present gating."));
613    }
614
615    let window_name = target.window_title_contains.clone();
616    let app = target.app.clone();
617    let bundle_id = target.bundle_id.clone();
618
619    let started = Instant::now();
620    let outcome = wait::wait_until_with_policy("gate.window-present", policy, || {
621        if let Some(app) = app.as_deref() {
622            return targets::window_present(&TargetSelector {
623                window_id: None,
624                active_window: false,
625                app: Some(app.to_string()),
626                window_name: window_name.clone(),
627            });
628        }
629
630        if let Some(bundle_id) = bundle_id.as_deref() {
631            if let Some(mapped_app) = targets::app_name_for_bundle_id(bundle_id)? {
632                return targets::window_present(&TargetSelector {
633                    window_id: None,
634                    active_window: false,
635                    app: Some(mapped_app),
636                    window_name: window_name.clone(),
637                });
638            }
639            return Ok(false);
640        }
641
642        Ok(false)
643    })
644    .map_err(|error| map_gate_error(operation, "window-present", policy.timeout_ms, None, error))?;
645
646    Ok(AxGateCheckResult {
647        gate: "window-present".to_string(),
648        terminal_status: "satisfied".to_string(),
649        attempts: outcome.attempts,
650        elapsed_ms: started.elapsed().as_millis() as u64,
651        matched_count: None,
652    })
653}
654
655#[allow(clippy::too_many_arguments)]
656fn run_gate_ax_selector<F>(
657    operation: &str,
658    gate_name: &str,
659    runner: &dyn ProcessRunner,
660    backend: &dyn AxBackendAdapter,
661    target: &AxTarget,
662    selector: &AxSelector,
663    policy: wait::WaitPolicy,
664    backend_timeout_ms: u64,
665    predicate: F,
666) -> Result<AxGateCheckResult, CliError>
667where
668    F: Fn(usize) -> bool,
669{
670    let mut last_matched_count = 0usize;
671    let started = Instant::now();
672    let outcome = wait::wait_until_with_policy(&format!("gate.{gate_name}"), policy, || {
673        let evaluation = evaluate_selector_against_backend(
674            runner,
675            backend,
676            target,
677            selector,
678            backend_timeout_ms,
679        )?;
680        last_matched_count = evaluation.matched_count;
681        Ok(predicate(evaluation.matched_count))
682    })
683    .map_err(|error| {
684        map_gate_error(
685            operation,
686            gate_name,
687            policy.timeout_ms,
688            Some(last_matched_count),
689            error,
690        )
691    })?;
692
693    Ok(AxGateCheckResult {
694        gate: gate_name.to_string(),
695        terminal_status: "satisfied".to_string(),
696        attempts: outcome.attempts,
697        elapsed_ms: started.elapsed().as_millis() as u64,
698        matched_count: Some(last_matched_count),
699    })
700}
701
702fn map_gate_error(
703    operation: &str,
704    gate_name: &str,
705    timeout_ms: u64,
706    matched_count: Option<usize>,
707    error: CliError,
708) -> CliError {
709    if error.message().contains("timed out waiting") {
710        let mut mapped = CliError::runtime(format!(
711            "{operation} pre-action gate `{gate_name}` timed out after {timeout_ms}ms"
712        ))
713        .with_operation(format!("{operation}.gate.{gate_name}"))
714        .with_hint("Increase --gate-timeout-ms or relax gate conditions for slower UIs.");
715        if let Some(count) = matched_count {
716            mapped = mapped.with_hint(format!(
717                "Last AX selector match count before timeout: {count}"
718            ));
719        }
720        return mapped;
721    }
722
723    error
724        .with_operation(format!("{operation}.gate.{gate_name}"))
725        .with_hint("Pre-action gate failed before mutation; fix the gate condition and retry.")
726}
727
728fn evaluate_postcondition_check(
729    runner: &dyn ProcessRunner,
730    backend: &dyn AxBackendAdapter,
731    target: &AxTarget,
732    node_id: &str,
733    check: &AxPostconditionCheck,
734    backend_timeout_ms: u64,
735) -> Result<(bool, Option<Value>), CliError> {
736    match check {
737        AxPostconditionCheck::Focused(expected) => {
738            let list = backend.list(
739                runner,
740                &AxListRequest {
741                    target: target.clone(),
742                    ..AxListRequest::default()
743                },
744                backend_timeout_ms.max(1),
745            )?;
746            let observed = list
747                .nodes
748                .into_iter()
749                .find(|node| node.node_id == node_id)
750                .map(|node| Value::Bool(node.focused));
751            let satisfied = observed.as_ref().and_then(Value::as_bool) == Some(*expected);
752            Ok((satisfied, observed))
753        }
754        AxPostconditionCheck::AttributeValue { name, expected } => {
755            let observed = backend
756                .attr_get(
757                    runner,
758                    &AxAttrGetRequest {
759                        target: target.clone(),
760                        selector: AxSelector {
761                            node_id: Some(node_id.to_string()),
762                            ..AxSelector::default()
763                        },
764                        name: name.clone(),
765                    },
766                    backend_timeout_ms.max(1),
767                )?
768                .value;
769            Ok((observed == *expected, Some(observed)))
770        }
771    }
772}
773
774fn map_postcondition_error(
775    operation: &str,
776    check: &AxPostconditionCheck,
777    timeout_ms: u64,
778    observed: Option<Value>,
779    error: CliError,
780) -> CliError {
781    if error.message().contains("timed out waiting") {
782        let observed_text = observed
783            .map(|value| value.to_string())
784            .unwrap_or_else(|| "<none>".to_string());
785        return CliError::runtime(format!(
786            "{operation} postcondition mismatch for `{}` after {timeout_ms}ms",
787            check.name()
788        ))
789        .with_operation(format!("{operation}.postcondition"))
790        .with_hint(format!(
791            "Expected={}, observed={observed_text}",
792            check.expected_value()
793        ))
794        .with_hint("Increase --postcondition-timeout-ms or adjust postcondition checks.");
795    }
796
797    error
798        .with_operation(format!("{operation}.postcondition"))
799        .with_hint("Postcondition evaluation failed after action execution.")
800}
801
802fn validate_selector_regex(flag: &str, pattern: Option<&str>) -> Result<(), CliError> {
803    if let Some(pattern) = pattern {
804        RegexBuilder::new(pattern)
805            .case_insensitive(true)
806            .build()
807            .map_err(|err| CliError::usage(format!("{flag} has invalid regex: {err}")))?;
808    }
809    Ok(())
810}
811
812fn apply_stage<F>(
813    stage: &str,
814    current: &mut Vec<&AxNode>,
815    stages: &mut Vec<AxSelectorExplainStage>,
816    predicate: F,
817) where
818    F: Fn(&AxNode) -> bool,
819{
820    let before_count = current.len();
821    current.retain(|node| predicate(node));
822    stages.push(AxSelectorExplainStage {
823        stage: stage.to_string(),
824        before_count,
825        after_count: current.len(),
826    });
827}
828
829enum TextMatcher {
830    Contains(String),
831    Exact(String),
832    Prefix(String),
833    Suffix(String),
834    Regex(Regex),
835}
836
837impl TextMatcher {
838    fn matches(&self, raw: &str) -> bool {
839        match self {
840            Self::Contains(needle) => raw.to_ascii_lowercase().contains(needle),
841            Self::Exact(needle) => raw.eq_ignore_ascii_case(needle),
842            Self::Prefix(needle) => raw
843                .to_ascii_lowercase()
844                .starts_with(&needle.to_ascii_lowercase()),
845            Self::Suffix(needle) => raw
846                .to_ascii_lowercase()
847                .ends_with(&needle.to_ascii_lowercase()),
848            Self::Regex(regex) => regex.is_match(raw),
849        }
850    }
851}
852
853fn build_text_matcher(raw: &str, strategy: AxMatchStrategy) -> Result<TextMatcher, CliError> {
854    let matcher = match strategy {
855        AxMatchStrategy::Contains => TextMatcher::Contains(raw.to_ascii_lowercase()),
856        AxMatchStrategy::Exact => TextMatcher::Exact(raw.to_string()),
857        AxMatchStrategy::Prefix => TextMatcher::Prefix(raw.to_string()),
858        AxMatchStrategy::Suffix => TextMatcher::Suffix(raw.to_string()),
859        AxMatchStrategy::Regex => TextMatcher::Regex(
860            RegexBuilder::new(raw)
861                .case_insensitive(true)
862                .build()
863                .map_err(|err| {
864                    CliError::usage(format!("--match-strategy regex pattern is invalid: {err}"))
865                })?,
866        ),
867    };
868    Ok(matcher)
869}
870
871#[cfg(test)]
872mod tests {
873    use super::{
874        AxActionGateOptions, AxSelectorInput, SelectorSelectionStatus, build_selector,
875        build_target, evaluate_selector_against_nodes, parse_postcondition_expected_value,
876        selector_selection_error,
877    };
878    use crate::model::{AxMatchStrategy, AxNode};
879    use pretty_assertions::assert_eq;
880
881    #[allow(clippy::too_many_arguments)]
882    fn node(
883        node_id: &str,
884        role: &str,
885        title: Option<&str>,
886        identifier: Option<&str>,
887        value_preview: Option<&str>,
888        subrole: Option<&str>,
889        focused: bool,
890        enabled: bool,
891    ) -> AxNode {
892        AxNode {
893            node_id: node_id.to_string(),
894            role: role.to_string(),
895            title: title.map(|v| v.to_string()),
896            identifier: identifier.map(|v| v.to_string()),
897            value_preview: value_preview.map(|v| v.to_string()),
898            subrole: subrole.map(|v| v.to_string()),
899            focused,
900            enabled,
901            ..AxNode::default()
902        }
903    }
904
905    #[test]
906    fn action_gate_options_any_enabled_checks_all_flags() {
907        let options = AxActionGateOptions::default();
908        assert!(!options.any_enabled());
909
910        let options = AxActionGateOptions {
911            app_active: true,
912            ..AxActionGateOptions::default()
913        };
914        assert!(options.any_enabled());
915    }
916
917    #[test]
918    fn build_target_rejects_conflicting_target_modes() {
919        let err = build_target(
920            Some("session".to_string()),
921            Some("Terminal".to_string()),
922            None,
923            None,
924        )
925        .expect_err("expected usage error");
926        assert!(
927            err.message()
928                .contains("--session-id cannot be combined with --app/--bundle-id")
929        );
930
931        let target = build_target(None, Some("Terminal".to_string()), None, None).expect("target");
932        assert_eq!(target.app.as_deref(), Some("Terminal"));
933        assert_eq!(target.bundle_id, None);
934    }
935
936    #[test]
937    fn build_selector_rejects_invalid_combinations() {
938        let err = build_selector(AxSelectorInput {
939            nth: Some(0),
940            ..AxSelectorInput::default()
941        })
942        .expect_err("nth=0 should fail");
943        assert!(err.message().contains("--nth must be at least 1"));
944
945        let err = build_selector(AxSelectorInput {
946            node_id: Some("node-1".to_string()),
947            role: Some("AXButton".to_string()),
948            ..AxSelectorInput::default()
949        })
950        .expect_err("node-id with other filters should fail");
951        assert!(err.message().contains("--node-id cannot be combined"));
952
953        let err = build_selector(AxSelectorInput {
954            nth: Some(1),
955            ..AxSelectorInput::default()
956        })
957        .expect_err("nth without filters should fail");
958        assert!(
959            err.message()
960                .contains("--nth requires at least one selector filter")
961        );
962
963        let err = build_selector(AxSelectorInput::default()).expect_err("missing filters");
964        assert!(
965            err.message()
966                .contains("provide --node-id or at least one selector filter")
967        );
968    }
969
970    #[test]
971    fn build_selector_validates_regex_patterns() {
972        let err = build_selector(AxSelectorInput {
973            title_contains: Some("(".to_string()),
974            match_strategy: AxMatchStrategy::Regex,
975            ..AxSelectorInput::default()
976        })
977        .expect_err("invalid regex should fail");
978        assert!(err.message().contains("invalid regex"));
979
980        let selector = build_selector(AxSelectorInput {
981            role: Some("AXButton".to_string()),
982            nth: Some(2),
983            ..AxSelectorInput::default()
984        })
985        .expect("valid selector");
986        assert_eq!(selector.role.as_deref(), Some("AXButton"));
987        assert_eq!(selector.nth, Some(2));
988    }
989
990    #[test]
991    fn evaluate_selector_by_node_id_and_role_filters() {
992        let nodes = vec![
993            node(
994                "node-1",
995                "AXButton",
996                Some("Save"),
997                Some("save"),
998                Some("save value"),
999                None,
1000                true,
1001                true,
1002            ),
1003            node(
1004                "node-2",
1005                "AXTextField",
1006                Some("Search"),
1007                Some("search"),
1008                Some("query"),
1009                Some("AXSearchField"),
1010                false,
1011                true,
1012            ),
1013        ];
1014
1015        let by_id = build_selector(AxSelectorInput {
1016            node_id: Some("node-1".to_string()),
1017            ..AxSelectorInput::default()
1018        })
1019        .expect("selector");
1020        let eval = evaluate_selector_against_nodes(&nodes, &by_id).expect("eval");
1021        assert_eq!(eval.matched_count, 1);
1022        assert_eq!(eval.selected_node_id.as_deref(), Some("node-1"));
1023        assert_eq!(eval.selection_status, SelectorSelectionStatus::Selected);
1024
1025        let by_role = build_selector(AxSelectorInput {
1026            role: Some("axtextfield".to_string()),
1027            ..AxSelectorInput::default()
1028        })
1029        .expect("selector");
1030        let eval = evaluate_selector_against_nodes(&nodes, &by_role).expect("eval");
1031        assert_eq!(eval.selection_status, SelectorSelectionStatus::Selected);
1032        assert_eq!(eval.selected_node_id.as_deref(), Some("node-2"));
1033    }
1034
1035    #[test]
1036    fn evaluate_selector_reports_ambiguous_and_nth_out_of_range() {
1037        let nodes = vec![
1038            node(
1039                "n1",
1040                "AXButton",
1041                Some("Save"),
1042                None,
1043                None,
1044                None,
1045                false,
1046                true,
1047            ),
1048            node(
1049                "n2",
1050                "AXButton",
1051                Some("Save As"),
1052                None,
1053                None,
1054                None,
1055                false,
1056                true,
1057            ),
1058        ];
1059
1060        let ambiguous = build_selector(AxSelectorInput {
1061            role: Some("AXButton".to_string()),
1062            ..AxSelectorInput::default()
1063        })
1064        .expect("selector");
1065        let eval = evaluate_selector_against_nodes(&nodes, &ambiguous).expect("eval");
1066        assert_eq!(eval.selection_status, SelectorSelectionStatus::Ambiguous);
1067        assert_eq!(eval.selected_node_id, None);
1068
1069        let nth = build_selector(AxSelectorInput {
1070            role: Some("AXButton".to_string()),
1071            nth: Some(3),
1072            ..AxSelectorInput::default()
1073        })
1074        .expect("selector");
1075        let eval = evaluate_selector_against_nodes(&nodes, &nth).expect("eval");
1076        assert_eq!(
1077            eval.selection_status,
1078            SelectorSelectionStatus::NthOutOfRange
1079        );
1080        assert_eq!(eval.selected_node_id, None);
1081    }
1082
1083    #[test]
1084    fn evaluate_selector_supports_match_strategies_and_explain_output() {
1085        let nodes = vec![
1086            node(
1087                "n1",
1088                "AXButton",
1089                Some("Save As"),
1090                Some("com.app.save"),
1091                Some("value one"),
1092                None,
1093                false,
1094                true,
1095            ),
1096            node(
1097                "n2",
1098                "AXButton",
1099                Some("Open"),
1100                Some("com.app.open"),
1101                Some("value two"),
1102                None,
1103                true,
1104                true,
1105            ),
1106        ];
1107
1108        let exact = build_selector(AxSelectorInput {
1109            title_contains: Some("save as".to_string()),
1110            match_strategy: AxMatchStrategy::Exact,
1111            explain: true,
1112            ..AxSelectorInput::default()
1113        })
1114        .expect("selector");
1115        let eval = evaluate_selector_against_nodes(&nodes, &exact).expect("eval");
1116        assert_eq!(eval.selection_status, SelectorSelectionStatus::Selected);
1117        assert_eq!(eval.selected_node_id.as_deref(), Some("n1"));
1118        let explain = eval.explain.expect("explain");
1119        assert_eq!(explain.strategy, AxMatchStrategy::Exact);
1120        assert!(!explain.stage_results.is_empty());
1121
1122        let prefix = build_selector(AxSelectorInput {
1123            identifier_contains: Some("com.app.op".to_string()),
1124            match_strategy: AxMatchStrategy::Prefix,
1125            ..AxSelectorInput::default()
1126        })
1127        .expect("selector");
1128        let eval = evaluate_selector_against_nodes(&nodes, &prefix).expect("eval");
1129        assert_eq!(eval.selected_node_id.as_deref(), Some("n2"));
1130
1131        let suffix = build_selector(AxSelectorInput {
1132            identifier_contains: Some(".save".to_string()),
1133            match_strategy: AxMatchStrategy::Suffix,
1134            ..AxSelectorInput::default()
1135        })
1136        .expect("selector");
1137        let eval = evaluate_selector_against_nodes(&nodes, &suffix).expect("eval");
1138        assert_eq!(eval.selected_node_id.as_deref(), Some("n1"));
1139
1140        let regex = build_selector(AxSelectorInput {
1141            value_contains: Some("value\\s+two".to_string()),
1142            match_strategy: AxMatchStrategy::Regex,
1143            ..AxSelectorInput::default()
1144        })
1145        .expect("selector");
1146        let eval = evaluate_selector_against_nodes(&nodes, &regex).expect("eval");
1147        assert_eq!(eval.selected_node_id.as_deref(), Some("n2"));
1148    }
1149
1150    #[test]
1151    fn selector_selection_error_and_postcondition_parsing_are_stable() {
1152        assert!(selector_selection_error("op", SelectorSelectionStatus::Selected).is_none());
1153
1154        let no_match = selector_selection_error("op", SelectorSelectionStatus::NoMatches)
1155            .expect("no-match error");
1156        assert!(
1157            no_match
1158                .message()
1159                .contains("selector returned zero AX matches")
1160        );
1161
1162        let ambiguous = selector_selection_error("op", SelectorSelectionStatus::Ambiguous)
1163            .expect("ambiguous error");
1164        assert!(ambiguous.message().contains("selector is ambiguous"));
1165
1166        assert_eq!(
1167            parse_postcondition_expected_value(r#"{"ok":true}"#),
1168            serde_json::json!({"ok": true})
1169        );
1170        assert_eq!(
1171            parse_postcondition_expected_value("plain"),
1172            serde_json::Value::String("plain".to_string())
1173        );
1174    }
1175}