Skip to main content

tldr_cli/
signals.rs

1//! Signal handling for graceful interruption (Phase 10)
2//!
3//! This module provides signal handling for Ctrl+C (SIGINT) interruption
4//! to allow graceful shutdown and partial result reporting.
5//!
6//! # Mitigations
7//!
8//! - A36: No signal handling for graceful interruption
9//!   - Catches SIGINT (Ctrl+C)
10//!   - Allows current file to complete
11//!   - Returns partial results with interrupt metadata
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use tldr_cli::signals::{setup_signal_handler, is_interrupted, InterruptState};
17//!
18//! // Set up handler at start of main
19//! let state = setup_signal_handler()?;
20//!
21//! // Check periodically in analysis loops
22//! for file in files {
23//!     if is_interrupted() {
24//!         eprintln!("Interrupted. Returning partial results...");
25//!         break;
26//!     }
27//!     process_file(file)?;
28//! }
29//!
30//! // Report partial results
31//! if state.was_interrupted() {
32//!     eprintln!("Analyzed {}/{} files before interrupt", state.files_completed(), total);
33//! }
34//! ```
35
36use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
37use std::sync::Arc;
38
39/// Global interrupted flag.
40///
41/// This is set to true when SIGINT is received.
42static INTERRUPTED: AtomicBool = AtomicBool::new(false);
43
44/// Check if the process has been interrupted.
45///
46/// Call this periodically in long-running loops to allow graceful shutdown.
47pub fn is_interrupted() -> bool {
48    INTERRUPTED.load(Ordering::Relaxed)
49}
50
51/// Reset the interrupted flag.
52///
53/// Useful for tests or if you want to handle multiple interrupts.
54pub fn reset_interrupted() {
55    INTERRUPTED.store(false, Ordering::SeqCst);
56}
57
58/// State tracking for interruptible operations.
59#[derive(Clone)]
60pub struct InterruptState {
61    /// Whether an interrupt was received
62    interrupted: Arc<AtomicBool>,
63    /// Number of items completed before interrupt
64    completed: Arc<AtomicUsize>,
65    /// Total items expected
66    total: Arc<AtomicUsize>,
67}
68
69impl InterruptState {
70    /// Create a new interrupt state.
71    pub fn new() -> Self {
72        Self {
73            interrupted: Arc::new(AtomicBool::new(false)),
74            completed: Arc::new(AtomicUsize::new(0)),
75            total: Arc::new(AtomicUsize::new(0)),
76        }
77    }
78
79    /// Set the total number of items.
80    pub fn set_total(&self, total: usize) {
81        self.total.store(total, Ordering::SeqCst);
82    }
83
84    /// Increment completed count.
85    pub fn increment_completed(&self) {
86        self.completed.fetch_add(1, Ordering::SeqCst);
87    }
88
89    /// Mark as interrupted.
90    pub fn mark_interrupted(&self) {
91        self.interrupted.store(true, Ordering::SeqCst);
92    }
93
94    /// Check if interrupted.
95    pub fn was_interrupted(&self) -> bool {
96        self.interrupted.load(Ordering::Relaxed) || is_interrupted()
97    }
98
99    /// Get completed count.
100    pub fn files_completed(&self) -> usize {
101        self.completed.load(Ordering::Relaxed)
102    }
103
104    /// Get total count.
105    pub fn total_files(&self) -> usize {
106        self.total.load(Ordering::Relaxed)
107    }
108
109    /// Check global interrupt flag and update local state.
110    ///
111    /// Returns `true` if interrupted.
112    pub fn check_interrupt(&self) -> bool {
113        if is_interrupted() {
114            self.mark_interrupted();
115            true
116        } else {
117            false
118        }
119    }
120}
121
122impl Default for InterruptState {
123    fn default() -> Self {
124        Self::new()
125    }
126}
127
128/// Set up the signal handler for SIGINT.
129///
130/// This should be called once at the start of main().
131/// Returns an InterruptState for tracking progress.
132///
133/// # Returns
134///
135/// * `Ok(InterruptState)` - Handler installed successfully
136/// * `Err(String)` - Failed to install handler
137pub fn setup_signal_handler() -> Result<InterruptState, String> {
138    let state = InterruptState::new();
139
140    ctrlc::set_handler(move || {
141        // Check if already interrupted (double Ctrl+C = force exit)
142        if INTERRUPTED.load(Ordering::Relaxed) {
143            eprintln!("\nForce exit.");
144            std::process::exit(130); // 128 + SIGINT (2)
145        }
146
147        INTERRUPTED.store(true, Ordering::SeqCst);
148        eprintln!("\nInterrupted. Completing current operation...");
149    })
150    .map_err(|e| format!("Failed to set signal handler: {}", e))?;
151
152    Ok(state)
153}
154
155/// Report on interrupt status.
156///
157/// Call this at the end of analysis to report partial results.
158pub fn report_interrupt_status(state: &InterruptState) {
159    if state.was_interrupted() {
160        let completed = state.files_completed();
161        let total = state.total_files();
162        if total > 0 {
163            eprintln!(
164                "Interrupted: Analyzed {}/{} files ({:.1}%)",
165                completed,
166                total,
167                (completed as f64 / total as f64) * 100.0
168            );
169        } else {
170            eprintln!("Interrupted: Analyzed {} files", completed);
171        }
172    }
173}
174
175/// Metadata about an interrupted analysis.
176#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
177pub struct InterruptMetadata {
178    /// Whether the analysis was interrupted
179    pub interrupted: bool,
180    /// Number of items completed
181    pub completed: usize,
182    /// Total items expected
183    pub total: usize,
184    /// Percentage complete
185    pub percent_complete: f64,
186}
187
188impl InterruptMetadata {
189    /// Create metadata from interrupt state.
190    pub fn from_state(state: &InterruptState) -> Self {
191        let completed = state.files_completed();
192        let total = state.total_files();
193        let percent = if total > 0 {
194            (completed as f64 / total as f64) * 100.0
195        } else {
196            0.0
197        };
198
199        Self {
200            interrupted: state.was_interrupted(),
201            completed,
202            total,
203            percent_complete: percent,
204        }
205    }
206
207    /// Create metadata for a non-interrupted analysis.
208    pub fn complete(total: usize) -> Self {
209        Self {
210            interrupted: false,
211            completed: total,
212            total,
213            percent_complete: 100.0,
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn test_interrupt_state_new() {
224        let state = InterruptState::new();
225        assert!(!state.was_interrupted());
226        assert_eq!(state.files_completed(), 0);
227        assert_eq!(state.total_files(), 0);
228    }
229
230    #[test]
231    fn test_interrupt_state_tracking() {
232        let state = InterruptState::new();
233        state.set_total(100);
234        state.increment_completed();
235        state.increment_completed();
236
237        assert_eq!(state.files_completed(), 2);
238        assert_eq!(state.total_files(), 100);
239    }
240
241    #[test]
242    fn test_interrupt_state_mark_interrupted() {
243        let state = InterruptState::new();
244        assert!(!state.was_interrupted());
245
246        state.mark_interrupted();
247        assert!(state.was_interrupted());
248    }
249
250    #[test]
251    fn test_interrupt_metadata_from_state() {
252        let state = InterruptState::new();
253        state.set_total(100);
254        for _ in 0..50 {
255            state.increment_completed();
256        }
257
258        let metadata = InterruptMetadata::from_state(&state);
259        assert_eq!(metadata.completed, 50);
260        assert_eq!(metadata.total, 100);
261        assert!((metadata.percent_complete - 50.0).abs() < 0.01);
262    }
263
264    #[test]
265    fn test_interrupt_metadata_complete() {
266        let metadata = InterruptMetadata::complete(100);
267        assert!(!metadata.interrupted);
268        assert_eq!(metadata.completed, 100);
269        assert_eq!(metadata.total, 100);
270        assert_eq!(metadata.percent_complete, 100.0);
271    }
272
273    #[test]
274    fn test_reset_interrupted() {
275        // Note: This test modifies global state
276        reset_interrupted();
277        assert!(!is_interrupted());
278    }
279}