Skip to main content

ralph_workflow/
exit_pause.rs

1use 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
43/// Wait for user confirmation before closing the process.
44///
45/// # Errors
46///
47/// Returns an error when writing the prompt to stderr fails or when stdin cannot be read.
48pub 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}