rgen_core/
inject.rs

1use anyhow::Result;
2use std::fs;
3use std::path::Path;
4
5/// EOL (End of Line) detection and normalization utilities.
6pub struct EolNormalizer;
7
8impl EolNormalizer {
9    /// Detect the EOL style used in a file.
10    ///
11    /// Returns the EOL string used in the file, or the platform default
12    /// if no EOL is detected or the file is empty.
13    pub fn detect_eol(file_path: &Path) -> Result<String> {
14        if !file_path.exists() {
15            return Ok(Self::platform_default());
16        }
17
18        let content = fs::read_to_string(file_path)?;
19        Self::detect_eol_from_content(&content)
20    }
21
22    /// Detect EOL from file content.
23    pub fn detect_eol_from_content(content: &str) -> Result<String> {
24        // Look for CRLF first (Windows)
25        if content.contains("\r\n") {
26            return Ok("\r\n".to_string());
27        }
28
29        // Look for LF (Unix/Linux/macOS)
30        if content.contains('\n') {
31            return Ok("\n".to_string());
32        }
33
34        // Look for CR (old Mac)
35        if content.contains('\r') {
36            return Ok("\r".to_string());
37        }
38
39        // No EOL detected, use platform default
40        Ok(Self::platform_default())
41    }
42
43    /// Get the platform default EOL.
44    pub fn platform_default() -> String {
45        if cfg!(windows) {
46            "\r\n".to_string()
47        } else {
48            "\n".to_string()
49        }
50    }
51
52    /// Normalize content to use the specified EOL.
53    pub fn normalize_to_eol(content: &str, target_eol: &str) -> String {
54        // First normalize to LF, then convert to target
55        let normalized = content
56            .replace("\r\n", "\n") // CRLF -> LF
57            .replace('\r', "\n"); // CR -> LF
58
59        // Convert to target EOL
60        normalized.replace('\n', target_eol)
61    }
62
63    /// Normalize content to match the EOL of a target file.
64    pub fn normalize_to_match_file(content: &str, target_file: &Path) -> Result<String> {
65        let target_eol = Self::detect_eol(target_file)?;
66        Ok(Self::normalize_to_eol(content, &target_eol))
67    }
68}
69
70/// Default skip_if pattern generator for injection templates.
71pub struct SkipIfGenerator;
72
73impl SkipIfGenerator {
74    /// Generate a default skip_if pattern for exact substring match.
75    ///
76    /// This creates a regex pattern that will match if the injection
77    /// content already exists in the target file.
78    pub fn generate_exact_match(content: &str) -> String {
79        // Escape special regex characters in the content
80        let escaped = regex::escape(content);
81        format!("(?s){}", escaped) // (?s) enables dotall mode for multiline matching
82    }
83
84    /// Check if content already exists in target file using exact substring match.
85    pub fn content_exists_in_file(content: &str, file_path: &Path) -> Result<bool> {
86        if !file_path.exists() {
87            return Ok(false);
88        }
89
90        let file_content = fs::read_to_string(file_path)?;
91        Ok(file_content.contains(content))
92    }
93
94    /// Generate a default skip_if for idempotent injection.
95    ///
96    /// This is more sophisticated than exact match - it looks for
97    /// content that has already been injected by checking for
98    /// rgen-specific markers or patterns.
99    pub fn generate_idempotent_pattern(content: &str) -> String {
100        // For now, use exact match
101        // In the future, this could be enhanced to look for
102        // rgen-specific markers or content signatures
103        Self::generate_exact_match(content)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use tempfile::NamedTempFile;
111
112    #[test]
113    fn test_detect_eol_crlf() -> Result<()> {
114        let temp_file = NamedTempFile::new()?;
115        fs::write(temp_file.path(), "line1\r\nline2\r\n")?;
116
117        let eol = EolNormalizer::detect_eol(temp_file.path())?;
118        assert_eq!(eol, "\r\n");
119
120        Ok(())
121    }
122
123    #[test]
124    fn test_detect_eol_lf() -> Result<()> {
125        let temp_file = NamedTempFile::new()?;
126        fs::write(temp_file.path(), "line1\nline2\n")?;
127
128        let eol = EolNormalizer::detect_eol(temp_file.path())?;
129        assert_eq!(eol, "\n");
130
131        Ok(())
132    }
133
134    #[test]
135    fn test_detect_eol_cr() -> Result<()> {
136        let temp_file = NamedTempFile::new()?;
137        fs::write(temp_file.path(), "line1\rline2\r")?;
138
139        let eol = EolNormalizer::detect_eol(temp_file.path())?;
140        assert_eq!(eol, "\r");
141
142        Ok(())
143    }
144
145    #[test]
146    fn test_detect_eol_no_eol() -> Result<()> {
147        let temp_file = NamedTempFile::new()?;
148        fs::write(temp_file.path(), "single line")?;
149
150        let eol = EolNormalizer::detect_eol(temp_file.path())?;
151        assert_eq!(eol, EolNormalizer::platform_default());
152
153        Ok(())
154    }
155
156    #[test]
157    fn test_detect_eol_nonexistent_file() -> Result<()> {
158        let eol = EolNormalizer::detect_eol(Path::new("/nonexistent/file"))?;
159        assert_eq!(eol, EolNormalizer::platform_default());
160
161        Ok(())
162    }
163
164    #[test]
165    fn test_normalize_to_eol() {
166        let content = "line1\r\nline2\rline3\nline4";
167
168        // Normalize to LF
169        let normalized_lf = EolNormalizer::normalize_to_eol(content, "\n");
170        assert_eq!(normalized_lf, "line1\nline2\nline3\nline4");
171
172        // Normalize to CRLF
173        let normalized_crlf = EolNormalizer::normalize_to_eol(content, "\r\n");
174        assert_eq!(normalized_crlf, "line1\r\nline2\r\nline3\r\nline4");
175
176        // Normalize to CR
177        let normalized_cr = EolNormalizer::normalize_to_eol(content, "\r");
178        assert_eq!(normalized_cr, "line1\rline2\rline3\rline4");
179    }
180
181    #[test]
182    fn test_normalize_to_match_file() -> Result<()> {
183        let temp_file = NamedTempFile::new()?;
184        fs::write(temp_file.path(), "existing\r\ncontent")?;
185
186        let content_to_inject = "new\ncontent";
187        let normalized =
188            EolNormalizer::normalize_to_match_file(content_to_inject, temp_file.path())?;
189
190        assert_eq!(normalized, "new\r\ncontent");
191
192        Ok(())
193    }
194
195    #[test]
196    fn test_generate_exact_match() {
197        let content = "function hello() {\n  console.log('world');\n}";
198        let pattern = SkipIfGenerator::generate_exact_match(content);
199
200        // Should be a valid regex pattern
201        assert!(regex::Regex::new(&pattern).is_ok());
202
203        // Should match the original content
204        let regex = regex::Regex::new(&pattern).unwrap();
205        assert!(regex.is_match(content));
206    }
207
208    #[test]
209    fn test_generate_exact_match_with_special_chars() {
210        let content = "function test() {\n  return /^[a-z]+$/;\n}";
211        let pattern = SkipIfGenerator::generate_exact_match(content);
212
213        // Should escape special regex characters
214        let regex = regex::Regex::new(&pattern).unwrap();
215        assert!(regex.is_match(content));
216
217        // Should not match similar but different content
218        let different_content = "function test() {\n  return /^[A-Z]+$/;\n}";
219        assert!(!regex.is_match(different_content));
220    }
221
222    #[test]
223    fn test_content_exists_in_file() -> Result<()> {
224        let temp_file = NamedTempFile::new()?;
225        let content = "function hello() {\n  console.log('world');\n}";
226        fs::write(
227            temp_file.path(),
228            &format!("// Header\n{}\n// Footer", content),
229        )?;
230
231        // Content should exist
232        assert!(SkipIfGenerator::content_exists_in_file(
233            content,
234            temp_file.path()
235        )?);
236
237        // Different content should not exist
238        let different_content = "function goodbye() {\n  console.log('moon');\n}";
239        assert!(!SkipIfGenerator::content_exists_in_file(
240            different_content,
241            temp_file.path()
242        )?);
243
244        Ok(())
245    }
246
247    #[test]
248    fn test_content_exists_in_nonexistent_file() -> Result<()> {
249        let result =
250            SkipIfGenerator::content_exists_in_file("content", Path::new("/nonexistent/file"))?;
251        assert!(!result);
252
253        Ok(())
254    }
255
256    #[test]
257    fn test_generate_idempotent_pattern() {
258        let content = "function test() {}";
259        let pattern = SkipIfGenerator::generate_idempotent_pattern(content);
260
261        // Should generate a valid regex
262        assert!(regex::Regex::new(&pattern).is_ok());
263
264        // Should match the content
265        let regex = regex::Regex::new(&pattern).unwrap();
266        assert!(regex.is_match(content));
267    }
268
269    #[test]
270    fn test_platform_default() {
271        let default = EolNormalizer::platform_default();
272
273        // Should be either \n or \r\n depending on platform
274        assert!(default == "\n" || default == "\r\n");
275    }
276}