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, ®ex).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}