Skip to main content

rusty_commit/utils/
thinking_strip.rs

1//! Utilities for stripping thinking tags from AI responses.
2//!
3//! This module provides functions to remove `<thinking>` tags and their content
4//! from AI model outputs, which is useful for reasoning models that include
5//! their thought process in the response.
6
7use regex::Regex;
8
9/// Strips `<thinking>` tags and their content from AI responses.
10///
11/// This handles various formats used by reasoning models:
12/// - `<thinking>...</thinking>`
13/// - `<think>...</think>`
14/// - `[THINKING]...[/THINKING]`
15/// - `[[THINKING]]...[[/THINKING]]`
16/// - Code block variations
17///
18/// # Examples
19///
20/// ```
21/// let input = "Here's the commit message:\n<thinking>I should write a clear message</thinking>\nfeat: add login";
22/// let output = rusty_commit::utils::strip_thinking(input);
23/// // The thinking block is removed, extra newlines are cleaned up
24/// assert!(output.contains("feat: add login"));
25/// ```
26pub fn strip_thinking(text: &str) -> String {
27    // Track content before and after thinking blocks
28    let mut result = String::with_capacity(text.len());
29    let mut current = text;
30    let mut has_thinking = false;
31
32    while !current.is_empty() {
33        // Find the next opening tag (case-insensitive)
34        let opening_pos = find_thinking_opening(current);
35
36        if let Some(start) = opening_pos {
37            // Add content before the opening tag
38            result.push_str(&current[..start]);
39
40            // Find the closing tag
41            let (after_content, found) = find_and_consume_thinking_block(&current[start..]);
42
43            if found {
44                has_thinking = true;
45                current = after_content;
46            } else {
47                // No closing tag found, include the rest
48                break;
49            }
50        } else {
51            // No more opening tags, add the rest
52            result.push_str(current);
53            break;
54        }
55    }
56
57    // Clean up any leading/trailing whitespace from removed blocks
58    if has_thinking {
59        cleanup_thinking_artifacts(&result)
60    } else {
61        result
62    }
63}
64
65/// Find the position of the opening thinking tag (case-insensitive)
66fn find_thinking_opening(text: &str) -> Option<usize> {
67    let lower = text.to_ascii_lowercase();
68
69    // Check for various opening tag patterns
70    let patterns = [
71        "<thinking>",
72        "<think>",
73        "[thinking]",
74        "[[thinking]]",
75        "```thinking",
76        "<!--thinking",
77    ];
78
79    let mut min_pos = None;
80
81    for pattern in &patterns {
82        if let Some(pos) = lower.find(pattern) {
83            if let Some(current_min) = min_pos {
84                if pos < current_min {
85                    min_pos = Some(pos);
86                }
87            } else {
88                min_pos = Some(pos);
89            }
90        }
91    }
92
93    min_pos
94}
95
96/// Find and consume a thinking block, returning what's after the closing tag
97fn find_and_consume_thinking_block(text: &str) -> (&str, bool) {
98    let lower = text.to_ascii_lowercase();
99
100    // Define opening and closing tag pairs
101    let tag_pairs = [
102        ("<thinking>", "</thinking>"),
103        ("<think>", "</think>"),
104        ("[thinking]", "[/thinking]"),
105        ("[[thinking]]", "[[/thinking]]"),
106        ("```thinking", "```"),
107        ("<!--thinking", "-->"),
108    ];
109
110    // Find the earliest closing tag after an opening tag
111    let mut earliest_closing: Option<usize> = None;
112    let mut earliest_after_closing: Option<&str> = None;
113
114    for (opening, closing) in &tag_pairs {
115        if let Some(opening_pos) = lower.find(opening) {
116            let content_after_opening = &text[opening_pos + opening.len()..];
117            if let Some(closing_pos) = content_after_opening.to_ascii_lowercase().find(closing) {
118                // closing_pos is relative to content_after_opening
119                let absolute_closing = opening_pos + opening.len() + closing_pos;
120
121                if let Some(current) = earliest_closing {
122                    if absolute_closing < current {
123                        earliest_closing = Some(absolute_closing);
124                        earliest_after_closing = Some(&text[absolute_closing + closing.len()..]);
125                    }
126                } else {
127                    earliest_closing = Some(absolute_closing);
128                    earliest_after_closing = Some(&text[absolute_closing + closing.len()..]);
129                }
130            }
131        }
132    }
133
134    if let Some(after) = earliest_after_closing {
135        (after, true)
136    } else {
137        (text, false)
138    }
139}
140
141/// Clean up artifacts left by removed thinking blocks
142fn cleanup_thinking_artifacts(text: &str) -> String {
143    // Remove multiple consecutive blank lines that may result from removing thinking blocks
144    let re = Regex::new(r"\n\s*\n\s*\n+").unwrap();
145    let result = re.replace_all(text, "\n\n");
146    result.trim().to_string()
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_strip_thinking_basic() {
155        let input = "feat: add login\n<thinking>I should write a clear message</thinking>";
156        let output = strip_thinking(input);
157        assert_eq!(output.trim(), "feat: add login");
158    }
159
160    #[test]
161    fn test_strip_thinking_anthropic_format() {
162        let input = "feat: add login\n<think> I should write a clear message</think>";
163        let output = strip_thinking(input);
164        assert_eq!(output.trim(), "feat: add login");
165    }
166
167    #[test]
168    fn test_strip_thinking_multiple_blocks() {
169        let input =
170            "First part\n<thinking>block 1</thinking>\nMiddle\n<thinking>block 2</thinking>\nLast";
171        let output = strip_thinking(input);
172        assert!(output.contains("First part"));
173        assert!(output.contains("Middle"));
174        assert!(output.contains("Last"));
175        assert!(!output.contains("<thinking>"));
176    }
177
178    #[test]
179    fn test_strip_thinking_no_thinking() {
180        let input = "feat: add login feature";
181        let output = strip_thinking(input);
182        assert_eq!(output, "feat: add login feature");
183    }
184
185    #[test]
186    fn test_strip_thinking_unclosed_tag() {
187        let input = "feat: add login\n<thinking unclosed";
188        let output = strip_thinking(input);
189        // Should include the unclosed tag since no closing tag was found
190        assert!(output.contains("<thinking"));
191    }
192}