macos_agent/backend/
input_source.rs1use crate::backend::process::{ProcessRequest, ProcessRunner, map_failure};
2use crate::error::CliError;
3use crate::test_mode;
4
5const TEST_INPUT_SOURCE_CURRENT_ENV: &str = "AGENTS_MACOS_AGENT_TEST_INPUT_SOURCE_CURRENT";
6const DEFAULT_ABC_SOURCE: &str = "com.apple.keylayout.ABC";
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct InputSourceSwitchState {
10 pub previous: String,
11 pub current: String,
12 pub switched: bool,
13}
14
15pub fn current(runner: &dyn ProcessRunner, timeout_ms: u64) -> Result<String, CliError> {
16 if test_mode::enabled() {
17 return Ok(std::env::var(TEST_INPUT_SOURCE_CURRENT_ENV)
18 .ok()
19 .filter(|value| !value.trim().is_empty())
20 .unwrap_or_else(|| DEFAULT_ABC_SOURCE.to_string()));
21 }
22
23 let request = ProcessRequest::new("im-select", Vec::new(), timeout_ms.max(1));
24 runner
25 .run(&request)
26 .map(|output| normalize_stdout(&output.stdout))
27 .map_err(|failure| {
28 map_failure("input-source.current", failure)
29 .with_hint("Install `im-select` via Homebrew: brew install im-select")
30 })
31}
32
33pub fn switch(
34 runner: &dyn ProcessRunner,
35 source_id: &str,
36 timeout_ms: u64,
37) -> Result<InputSourceSwitchState, CliError> {
38 let target = normalize_input_source_token(source_id);
39 if target.trim().is_empty() {
40 return Err(CliError::usage("--id cannot be empty").with_operation("input-source.switch"));
41 }
42
43 let previous = current(runner, timeout_ms)?;
44 if test_mode::enabled() {
45 return Ok(InputSourceSwitchState {
46 switched: !previous.eq_ignore_ascii_case(&target),
47 previous,
48 current: target,
49 });
50 }
51
52 let request = ProcessRequest::new("im-select", vec![target.clone()], timeout_ms.max(1));
53 runner.run(&request).map_err(|failure| {
54 map_failure("input-source.switch", failure)
55 .with_hint("Install `im-select` via Homebrew: brew install im-select")
56 })?;
57
58 let current = current(runner, timeout_ms)?;
59 Ok(InputSourceSwitchState {
60 switched: !previous.eq_ignore_ascii_case(¤t),
61 previous,
62 current,
63 })
64}
65
66pub fn normalize_input_source_token(raw: &str) -> String {
67 let trimmed = raw.trim();
68 match trimmed.to_ascii_lowercase().as_str() {
69 "abc" | "english" | "us" | "u.s." => DEFAULT_ABC_SOURCE.to_string(),
70 _ => trimmed.to_string(),
71 }
72}
73
74fn normalize_stdout(raw: &str) -> String {
75 raw.trim().to_string()
76}
77
78#[cfg(test)]
79mod tests {
80 use nils_test_support::{EnvGuard, GlobalStateLock};
81 use pretty_assertions::assert_eq;
82
83 use crate::backend::process::{ProcessFailure, ProcessOutput, ProcessRequest, ProcessRunner};
84
85 use super::{current, normalize_input_source_token, switch};
86
87 struct FixedRunner {
88 stdout: String,
89 }
90
91 impl FixedRunner {
92 fn new(stdout: impl Into<String>) -> Self {
93 Self {
94 stdout: stdout.into(),
95 }
96 }
97 }
98
99 impl ProcessRunner for FixedRunner {
100 fn run(&self, _request: &ProcessRequest) -> Result<ProcessOutput, ProcessFailure> {
101 Ok(ProcessOutput {
102 stdout: self.stdout.clone(),
103 stderr: String::new(),
104 })
105 }
106 }
107
108 #[test]
109 fn normalize_token_maps_common_aliases() {
110 assert_eq!(
111 normalize_input_source_token("abc"),
112 "com.apple.keylayout.ABC"
113 );
114 assert_eq!(
115 normalize_input_source_token("US"),
116 "com.apple.keylayout.ABC"
117 );
118 }
119
120 #[test]
121 fn normalize_token_preserves_case_for_full_source_id() {
122 assert_eq!(
123 normalize_input_source_token("com.apple.keylayout.ABC"),
124 "com.apple.keylayout.ABC"
125 );
126 }
127
128 #[test]
129 fn current_uses_test_mode_env_when_enabled() {
130 let lock = GlobalStateLock::new();
131 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
132 let _value = EnvGuard::set(
133 &lock,
134 "AGENTS_MACOS_AGENT_TEST_INPUT_SOURCE_CURRENT",
135 "com.apple.keylayout.US",
136 );
137 let out = current(&FixedRunner::new("ignored"), 100).expect("test mode current");
138 assert_eq!(out, "com.apple.keylayout.US");
139 }
140
141 #[test]
142 fn switch_returns_simulated_state_in_test_mode() {
143 let lock = GlobalStateLock::new();
144 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
145 let _value = EnvGuard::set(
146 &lock,
147 "AGENTS_MACOS_AGENT_TEST_INPUT_SOURCE_CURRENT",
148 "com.apple.keylayout.US",
149 );
150 let state = switch(&FixedRunner::new("ignored"), "abc", 100).expect("switch");
151 assert!(state.switched);
152 assert_eq!(state.previous, "com.apple.keylayout.US");
153 assert_eq!(state.current, "com.apple.keylayout.ABC");
154 }
155}