Skip to main content

macos_agent/backend/
input_source.rs

1use 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(&current),
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}