ralph_workflow/
exit_pause.rs1use crate::cli::PauseOnExitMode;
2use std::io::Write;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ExitOutcome {
6 Success,
7 Failure,
8 Interrupted,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct LaunchContext {
13 pub is_windows: bool,
14 pub has_terminal_session_marker: bool,
15 pub parent_process_name: Option<String>,
16}
17
18#[must_use]
19pub fn should_pause_before_exit(
20 mode: PauseOnExitMode,
21 outcome: ExitOutcome,
22 launch_context: &LaunchContext,
23) -> bool {
24 match mode {
25 PauseOnExitMode::Never => false,
26 PauseOnExitMode::Always => true,
27 PauseOnExitMode::Auto => {
28 matches!(outcome, ExitOutcome::Failure)
29 && is_probably_standalone_windows_launch(launch_context)
30 }
31 }
32}
33
34#[must_use]
35pub fn detect_launch_context() -> LaunchContext {
36 LaunchContext {
37 is_windows: cfg!(windows),
38 has_terminal_session_marker: has_terminal_session_marker(),
39 parent_process_name: detect_parent_process_name(),
40 }
41}
42
43pub fn pause_for_enter() -> std::io::Result<()> {
49 eprint!("\nPress Enter to close... ");
50 std::io::stderr().flush()?;
51
52 let mut line = String::new();
53 std::io::stdin().read_line(&mut line)?;
54
55 Ok(())
56}
57
58fn is_probably_standalone_windows_launch(launch_context: &LaunchContext) -> bool {
59 if !launch_context.is_windows || launch_context.has_terminal_session_marker {
60 return false;
61 }
62
63 launch_context
64 .parent_process_name
65 .as_deref()
66 .is_some_and(|name| normalize_process_name(name) == "explorer.exe")
67}
68
69fn has_terminal_session_marker() -> bool {
70 const TERMINAL_MARKERS: [&str; 7] = [
71 "WT_SESSION",
72 "TERM",
73 "MSYSTEM",
74 "ConEmuPID",
75 "ALACRITTY_LOG",
76 "TERM_PROGRAM",
77 "VSCODE_GIT_IPC_HANDLE",
78 ];
79
80 TERMINAL_MARKERS.iter().any(|key| {
81 std::env::var_os(key).is_some_and(|value| !value.to_string_lossy().trim().is_empty())
82 })
83}
84
85fn normalize_process_name(name: &str) -> String {
86 let normalized = name.trim().to_ascii_lowercase();
87 if std::path::Path::new(&normalized)
88 .extension()
89 .is_some_and(|ext| ext.eq_ignore_ascii_case("exe"))
90 {
91 normalized
92 } else {
93 format!("{normalized}.exe")
94 }
95}
96
97#[cfg(windows)]
98fn detect_parent_process_name() -> Option<String> {
99 use std::process::Command;
100
101 let script = format!(
102 "$p=(Get-CimInstance Win32_Process -Filter \"ProcessId = {}\").ParentProcessId; if ($p) {{ (Get-Process -Id $p -ErrorAction SilentlyContinue).ProcessName }}",
103 std::process::id()
104 );
105
106 let output = Command::new("powershell")
107 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
108 .output()
109 .ok()?;
110
111 if !output.status.success() {
112 return None;
113 }
114
115 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
116 (!name.is_empty()).then_some(name)
117}
118
119#[cfg(not(windows))]
120const fn detect_parent_process_name() -> Option<String> {
121 None
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 fn windows_context(parent: Option<&str>, has_marker: bool) -> LaunchContext {
129 LaunchContext {
130 is_windows: true,
131 has_terminal_session_marker: has_marker,
132 parent_process_name: parent.map(ToString::to_string),
133 }
134 }
135
136 #[test]
137 fn test_auto_pauses_on_failure_when_launched_from_explorer() {
138 let context = windows_context(Some("explorer.exe"), false);
139 assert!(should_pause_before_exit(
140 PauseOnExitMode::Auto,
141 ExitOutcome::Failure,
142 &context,
143 ));
144 }
145
146 #[test]
147 fn test_auto_does_not_pause_on_success() {
148 let context = windows_context(Some("explorer.exe"), false);
149 assert!(!should_pause_before_exit(
150 PauseOnExitMode::Auto,
151 ExitOutcome::Success,
152 &context,
153 ));
154 }
155
156 #[test]
157 fn test_auto_does_not_pause_when_terminal_session_marker_exists() {
158 let context = windows_context(Some("explorer.exe"), true);
159 assert!(!should_pause_before_exit(
160 PauseOnExitMode::Auto,
161 ExitOutcome::Failure,
162 &context,
163 ));
164 }
165
166 #[test]
167 fn test_auto_does_not_pause_on_non_windows() {
168 let context = LaunchContext {
169 is_windows: false,
170 has_terminal_session_marker: false,
171 parent_process_name: Some("explorer.exe".to_string()),
172 };
173 assert!(!should_pause_before_exit(
174 PauseOnExitMode::Auto,
175 ExitOutcome::Failure,
176 &context,
177 ));
178 }
179
180 #[test]
181 fn test_always_pauses_even_on_success() {
182 let context = windows_context(Some("cmd.exe"), true);
183 assert!(should_pause_before_exit(
184 PauseOnExitMode::Always,
185 ExitOutcome::Success,
186 &context,
187 ));
188 }
189
190 #[test]
191 fn test_never_never_pauses() {
192 let context = windows_context(Some("explorer.exe"), false);
193 assert!(!should_pause_before_exit(
194 PauseOnExitMode::Never,
195 ExitOutcome::Failure,
196 &context,
197 ));
198 }
199
200 #[test]
201 fn test_auto_does_not_pause_on_interrupted() {
202 let context = windows_context(Some("explorer.exe"), false);
203 assert!(!should_pause_before_exit(
204 PauseOnExitMode::Auto,
205 ExitOutcome::Interrupted,
206 &context,
207 ));
208 }
209}