Skip to main content

ralph_workflow/exit_pause/
io.rs

1// exit_pause/io.rs — boundary module for exit pause operations.
2// File stem is `io` — recognized as boundary module by forbid_io_effects lint.
3
4use 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
83/// Exit the process with the SIGINT exit code (130).
84///
85/// Called when the pipeline was interrupted by Ctrl+C and all cleanup
86/// has completed. Lives in the boundary module because `std::process::exit`
87/// is a process-level I/O effect.
88pub 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}