ralph_workflow/exit_pause/
io.rs1use crate::cli::PauseOnExitMode;
5use crate::executor::ProcessOutput;
6
7pub trait EnvironmentReader: Send {
8 fn var_os(&self, key: &str) -> Option<std::ffi::OsString>;
9}
10
11pub struct StdEnvironment;
12
13impl EnvironmentReader for StdEnvironment {
14 fn var_os(&self, key: &str) -> Option<std::ffi::OsString> {
15 std::env::var_os(key)
16 }
17}
18
19pub trait ProcessSpawner: Send {
20 fn spawn(&self, program: &str, args: &[&str]) -> Option<ProcessOutput>;
21}
22
23pub struct StdProcessSpawner;
24
25impl ProcessSpawner for StdProcessSpawner {
26 fn spawn(&self, program: &str, args: &[&str]) -> Option<ProcessOutput> {
27 let output = std::process::Command::new(program).args(args).output().ok()?;
28 Some(ProcessOutput {
29 status: output.status,
30 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
31 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
32 })
33 }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ExitOutcome {
38 Success,
39 Failure,
40 Interrupted,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct LaunchContext {
45 pub is_windows: bool,
46 pub has_terminal_session_marker: bool,
47 pub parent_process_name: Option<String>,
48}
49
50fn is_failure_outcome(outcome: ExitOutcome) -> bool {
51 matches!(outcome, ExitOutcome::Failure)
52}
53
54fn should_auto_pause(outcome: ExitOutcome, launch_context: &LaunchContext) -> bool {
55 is_failure_outcome(outcome) && is_probably_standalone_windows_launch(launch_context)
56}
57
58#[must_use]
59pub fn should_pause_before_exit(
60 mode: PauseOnExitMode,
61 outcome: ExitOutcome,
62 launch_context: &LaunchContext,
63) -> bool {
64 match mode {
65 PauseOnExitMode::Never => false,
66 PauseOnExitMode::Always => true,
67 PauseOnExitMode::Auto => should_auto_pause(outcome, launch_context),
68 }
69}
70
71#[must_use]
72pub fn detect_launch_context_with(
73 env: impl EnvironmentReader,
74 spawner: impl ProcessSpawner,
75) -> LaunchContext {
76 LaunchContext {
77 is_windows: cfg!(windows),
78 has_terminal_session_marker: has_terminal_session_marker_with(&env),
79 parent_process_name: detect_parent_process_name_with(spawner),
80 }
81}
82
83pub fn exit_with_sigint_code() -> ! {
89 std::process::exit(130);
90}
91
92pub fn pause_for_enter() -> std::io::Result<()> {
93 crate::io::terminal::pause_for_enter_with(std::io::stdin(), std::io::stderr())
94}
95
96pub fn pause_for_enter_with(
97 input: impl crate::io::terminal::TerminalInput,
98 output: impl crate::io::terminal::TerminalOutput,
99) -> std::io::Result<()> {
100 crate::io::terminal::pause_for_enter_with(input, output)
101}
102
103fn is_probably_standalone_windows_launch(launch_context: &LaunchContext) -> bool {
104 if !launch_context.is_windows || launch_context.has_terminal_session_marker {
105 return false;
106 }
107
108 launch_context
109 .parent_process_name
110 .as_deref()
111 .is_some_and(|name| normalize_process_name(name) == "explorer.exe")
112}
113
114fn has_terminal_session_marker_with(env: &impl EnvironmentReader) -> bool {
115 const TERMINAL_MARKERS: [&str; 7] = [
116 "WT_SESSION",
117 "TERM",
118 "MSYSTEM",
119 "ConEmuPID",
120 "ALACRITTY_LOG",
121 "TERM_PROGRAM",
122 "VSCODE_GIT_IPC_HANDLE",
123 ];
124
125 TERMINAL_MARKERS.iter().copied().any(|key| {
126 env.var_os(key)
127 .is_some_and(|value| !value.to_string_lossy().trim().is_empty())
128 })
129}
130
131fn normalize_process_name(name: &str) -> String {
132 let normalized = name.trim().to_ascii_lowercase();
133 if std::path::Path::new(&normalized)
134 .extension()
135 .is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
136 {
137 normalized
138 } else {
139 format!("{normalized}.exe")
140 }
141}
142
143#[cfg(windows)]
144fn detect_parent_process_name_with(spawner: impl ProcessSpawner) -> Option<String> {
145 let script = format!(
146 "$p=(Get-CimInstance Win32_Process -Filter \"ProcessId = {}\").ParentProcessId; if ($p) {{ (Get-Process -Id $p -ErrorAction SilentlyContinue).ProcessName }}",
147 std::process::id()
148 );
149
150 let output = spawner.spawn(
151 "powershell",
152 &["-NoProfile", "-NonInteractive", "-Command", &script],
153 )?;
154
155 if !output.succeeded() {
156 return None;
157 }
158
159 let name = output.stdout.trim().to_string();
160 (!name.is_empty()).then_some(name)
161}
162
163#[cfg(not(windows))]
164fn detect_parent_process_name_with(_spawner: impl ProcessSpawner) -> Option<String> {
165 None
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 fn windows_context(parent: Option<&str>, has_marker: bool) -> LaunchContext {
173 LaunchContext {
174 is_windows: true,
175 has_terminal_session_marker: has_marker,
176 parent_process_name: parent.map(ToString::to_string),
177 }
178 }
179
180 struct MockEnv {
181 vars: std::collections::HashMap<String, std::ffi::OsString>,
182 }
183
184 impl MockEnv {
185 fn new() -> Self {
186 Self {
187 vars: std::collections::HashMap::new(),
188 }
189 }
190
191 fn with_var(self, key: &str, value: &str) -> Self {
192 Self {
193 vars: self
194 .vars
195 .into_iter()
196 .chain([(key.to_string(), value.into())])
197 .collect(),
198 }
199 }
200 }
201
202 impl EnvironmentReader for MockEnv {
203 fn var_os(&self, key: &str) -> Option<std::ffi::OsString> {
204 self.vars.get(key).cloned()
205 }
206 }
207
208 struct MockSpawner {
209 output: Option<ProcessOutput>,
210 }
211
212 impl MockSpawner {
213 fn no_output() -> Self {
214 Self { output: None }
215 }
216 }
217
218 impl ProcessSpawner for MockSpawner {
219 fn spawn(&self, _program: &str, _args: &[&str]) -> Option<ProcessOutput> {
220 self.output.clone()
221 }
222 }
223
224 #[test]
225 fn test_auto_pauses_on_failure_when_launched_from_explorer() {
226 let context = windows_context(Some("explorer.exe"), false);
227 assert!(should_pause_before_exit(
228 PauseOnExitMode::Auto,
229 ExitOutcome::Failure,
230 &context,
231 ));
232 }
233
234 #[test]
235 fn test_auto_does_not_pause_on_success() {
236 let context = windows_context(Some("explorer.exe"), false);
237 assert!(!should_pause_before_exit(
238 PauseOnExitMode::Auto,
239 ExitOutcome::Success,
240 &context,
241 ));
242 }
243
244 #[test]
245 fn test_auto_does_not_pause_when_terminal_session_marker_exists() {
246 let context = windows_context(Some("explorer.exe"), true);
247 assert!(!should_pause_before_exit(
248 PauseOnExitMode::Auto,
249 ExitOutcome::Failure,
250 &context,
251 ));
252 }
253
254 #[test]
255 fn test_auto_does_not_pause_on_non_windows() {
256 let context = LaunchContext {
257 is_windows: false,
258 has_terminal_session_marker: false,
259 parent_process_name: Some("explorer.exe".to_string()),
260 };
261 assert!(!should_pause_before_exit(
262 PauseOnExitMode::Auto,
263 ExitOutcome::Failure,
264 &context,
265 ));
266 }
267
268 #[test]
269 fn test_always_pauses_even_on_success() {
270 let context = windows_context(Some("cmd.exe"), true);
271 assert!(should_pause_before_exit(
272 PauseOnExitMode::Always,
273 ExitOutcome::Success,
274 &context,
275 ));
276 }
277
278 #[test]
279 fn test_never_never_pauses() {
280 let context = windows_context(Some("explorer.exe"), false);
281 assert!(!should_pause_before_exit(
282 PauseOnExitMode::Never,
283 ExitOutcome::Failure,
284 &context,
285 ));
286 }
287
288 #[test]
289 fn test_auto_does_not_pause_on_interrupted() {
290 let context = windows_context(Some("explorer.exe"), false);
291 assert!(!should_pause_before_exit(
292 PauseOnExitMode::Auto,
293 ExitOutcome::Interrupted,
294 &context,
295 ));
296 }
297
298 #[test]
299 fn test_terminal_marker_detection_with_mock_env() {
300 let env = MockEnv::new().with_var("TERM", "xterm-256color");
301 assert!(has_terminal_session_marker_with(&env));
302
303 let env_no_marker = MockEnv::new();
304 assert!(!has_terminal_session_marker_with(&env_no_marker));
305 }
306
307 #[test]
308 fn test_launch_context_with_deps() {
309 let env = MockEnv::new().with_var("TERM", "xterm");
310 let spawner = MockSpawner::no_output();
311
312 let context = detect_launch_context_with(env, spawner);
313 assert!(context.has_terminal_session_marker);
314 }
315}