log_watcher/
utils.rs

1use anyhow::{Context, Result};
2use std::fs::File;
3use std::io::{BufRead, BufReader};
4use std::path::Path;
5
6/// Read all lines from a file
7pub fn read_file_from_end<P: AsRef<Path>>(path: P, _buffer_size: usize) -> Result<Vec<String>> {
8    let file = File::open(&path)
9        .with_context(|| format!("Failed to open file: {}", path.as_ref().display()))?;
10
11    let reader = BufReader::new(file);
12    let mut lines = Vec::new();
13
14    for line_result in reader.lines() {
15        let line = line_result?;
16        if !line.trim().is_empty() {
17            lines.push(line.trim().to_string());
18        }
19    }
20
21    Ok(lines)
22}
23
24/// Check if a file exists and is readable
25pub fn is_file_readable<P: AsRef<Path>>(path: P) -> bool {
26    File::open(path).is_ok()
27}
28
29/// Get file size
30pub fn get_file_size<P: AsRef<Path>>(path: P) -> Result<u64> {
31    let metadata = std::fs::metadata(path).with_context(|| "Failed to get file metadata")?;
32    Ok(metadata.len())
33}
34
35/// Check if a file has been rotated (size decreased)
36pub fn is_file_rotated<P: AsRef<Path>>(path: P, previous_size: u64) -> Result<bool> {
37    let current_size = get_file_size(path)?;
38    Ok(current_size < previous_size)
39}
40
41/// Validate that all files exist and are readable
42pub fn validate_files<P: AsRef<Path> + Clone>(files: &[P]) -> Result<Vec<P>> {
43    let mut valid_files = Vec::new();
44    let mut errors = Vec::new();
45
46    for file in files {
47        if is_file_readable(file) {
48            valid_files.push(file.clone());
49        } else {
50            errors.push(format!("File not readable: {}", file.as_ref().display()));
51        }
52    }
53
54    if valid_files.is_empty() {
55        return Err(anyhow::anyhow!(
56            "No valid files to watch: {}",
57            errors.join(", ")
58        ));
59    }
60
61    if !errors.is_empty() {
62        eprintln!(
63            "Warning: Some files are not accessible: {}",
64            errors.join(", ")
65        );
66    }
67
68    Ok(valid_files)
69}
70
71/// Format file size in human-readable format
72pub fn format_file_size(size: u64) -> String {
73    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
74    let mut size = size as f64;
75    let mut unit_index = 0;
76
77    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
78        size /= 1024.0;
79        unit_index += 1;
80    }
81
82    if unit_index == 0 {
83        format!("{} {}", size as u64, UNITS[unit_index])
84    } else {
85        format!("{:.1} {}", size, UNITS[unit_index])
86    }
87}
88
89/// Get filename from path
90pub fn get_filename<P: AsRef<Path>>(path: P) -> String {
91    path.as_ref()
92        .file_name()
93        .and_then(|name| name.to_str())
94        .unwrap_or("unknown")
95        .to_string()
96}
97
98/// Check if a path is a symlink
99pub fn is_symlink<P: AsRef<Path>>(path: P) -> bool {
100    path.as_ref()
101        .symlink_metadata()
102        .map(|metadata| metadata.file_type().is_symlink())
103        .unwrap_or(false)
104}
105
106/// Resolve symlink to actual path
107pub fn resolve_symlink<P: AsRef<Path>>(path: P) -> Result<std::path::PathBuf> {
108    let resolved = path
109        .as_ref()
110        .read_link()
111        .with_context(|| format!("Failed to read symlink: {}", path.as_ref().display()))?;
112    Ok(resolved)
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use std::io::Write;
119    use tempfile::NamedTempFile;
120
121    #[test]
122    fn test_format_file_size() {
123        assert_eq!(format_file_size(1024), "1.0 KB");
124        assert_eq!(format_file_size(1536), "1.5 KB");
125        assert_eq!(format_file_size(1048576), "1.0 MB");
126        assert_eq!(format_file_size(1023), "1023 B");
127    }
128
129    #[test]
130    fn test_get_filename() {
131        assert_eq!(get_filename("/path/to/file.log"), "file.log");
132        assert_eq!(get_filename("file.log"), "file.log");
133        assert_eq!(get_filename("/"), "unknown");
134    }
135
136    #[test]
137    fn test_validate_files() {
138        // Create a temporary file
139        let temp_file = NamedTempFile::new().unwrap();
140        let temp_path = temp_file.path();
141
142        // Test with valid file
143        let valid_files = vec![temp_path];
144        let result = validate_files(&valid_files);
145        assert!(result.is_ok());
146        assert_eq!(result.unwrap().len(), 1);
147
148        // Test with invalid file
149        let invalid_files = vec![std::path::Path::new("/nonexistent/file.log")];
150        let result = validate_files(&invalid_files);
151        assert!(result.is_err());
152    }
153
154    #[test]
155    fn test_read_file_from_end() {
156        let mut temp_file = NamedTempFile::new().unwrap();
157        writeln!(temp_file, "line 1").unwrap();
158        writeln!(temp_file, "line 2").unwrap();
159        writeln!(temp_file, "line 3").unwrap();
160        temp_file.flush().unwrap();
161
162        let lines = read_file_from_end(temp_file.path(), 1024).unwrap();
163        assert_eq!(lines.len(), 3);
164        assert_eq!(lines[0], "line 1");
165        assert_eq!(lines[1], "line 2");
166        assert_eq!(lines[2], "line 3");
167    }
168
169    #[test]
170    fn test_read_file_from_end_with_empty_lines() {
171        let mut temp_file = NamedTempFile::new().unwrap();
172        writeln!(temp_file, "line 1").unwrap();
173        writeln!(temp_file).unwrap(); // Empty line
174        writeln!(temp_file, "   ").unwrap(); // Whitespace only line
175        writeln!(temp_file, "line 2").unwrap();
176        temp_file.flush().unwrap();
177
178        let lines = read_file_from_end(temp_file.path(), 1024).unwrap();
179        assert_eq!(lines.len(), 2); // Empty lines should be filtered out
180        assert_eq!(lines[0], "line 1");
181        assert_eq!(lines[1], "line 2");
182    }
183
184    #[test]
185    fn test_read_file_from_end_with_nonexistent_file() {
186        let result = read_file_from_end("/nonexistent/file.log", 1024);
187        assert!(result.is_err());
188        assert!(result
189            .unwrap_err()
190            .to_string()
191            .contains("Failed to open file"));
192    }
193
194    #[test]
195    fn test_is_file_readable() {
196        let temp_file = NamedTempFile::new().unwrap();
197        assert!(is_file_readable(temp_file.path()));
198
199        assert!(!is_file_readable("/nonexistent/file.log"));
200    }
201
202    #[test]
203    fn test_get_file_size() {
204        let mut temp_file = NamedTempFile::new().unwrap();
205        writeln!(temp_file, "test content").unwrap();
206        temp_file.flush().unwrap();
207
208        let size = get_file_size(temp_file.path()).unwrap();
209        assert!(size > 0);
210    }
211
212    #[test]
213    fn test_get_file_size_nonexistent() {
214        let result = get_file_size("/nonexistent/file.log");
215        assert!(result.is_err());
216        assert!(result
217            .unwrap_err()
218            .to_string()
219            .contains("Failed to get file metadata"));
220    }
221
222    #[test]
223    fn test_is_file_rotated() {
224        let mut temp_file = NamedTempFile::new().unwrap();
225        writeln!(temp_file, "initial content").unwrap();
226        temp_file.flush().unwrap();
227
228        let initial_size = get_file_size(temp_file.path()).unwrap();
229
230        // Add more content
231        writeln!(temp_file, "more content").unwrap();
232        temp_file.flush().unwrap();
233
234        // File should not be rotated (size increased)
235        assert!(!is_file_rotated(temp_file.path(), initial_size).unwrap());
236
237        // Simulate file rotation by truncating
238        temp_file.as_file_mut().set_len(0).unwrap();
239        temp_file.flush().unwrap();
240
241        // File should be detected as rotated (size decreased)
242        assert!(is_file_rotated(temp_file.path(), initial_size).unwrap());
243    }
244
245    #[test]
246    fn test_validate_files_with_mixed_validity() {
247        let temp_file = NamedTempFile::new().unwrap();
248        let temp_path = temp_file.path();
249
250        let files = vec![
251            temp_path,
252            std::path::Path::new("/nonexistent/file1.log"),
253            std::path::Path::new("/nonexistent/file2.log"),
254        ];
255
256        let result = validate_files(&files);
257        assert!(result.is_ok());
258
259        let valid_files = result.unwrap();
260        assert_eq!(valid_files.len(), 1);
261        assert_eq!(valid_files[0], temp_path);
262    }
263
264    #[test]
265    fn test_validate_files_all_invalid() {
266        let files = vec![
267            std::path::Path::new("/nonexistent/file1.log"),
268            std::path::Path::new("/nonexistent/file2.log"),
269        ];
270
271        let result = validate_files(&files);
272        assert!(result.is_err());
273        assert!(result
274            .unwrap_err()
275            .to_string()
276            .contains("No valid files to watch"));
277    }
278
279    #[test]
280    fn test_format_file_size_edge_cases() {
281        // Test bytes
282        assert_eq!(format_file_size(0), "0 B");
283        assert_eq!(format_file_size(1), "1 B");
284        assert_eq!(format_file_size(1023), "1023 B");
285
286        // Test kilobytes
287        assert_eq!(format_file_size(1024), "1.0 KB");
288        assert_eq!(format_file_size(1536), "1.5 KB");
289        assert_eq!(format_file_size(2048), "2.0 KB");
290
291        // Test megabytes
292        assert_eq!(format_file_size(1048576), "1.0 MB");
293        assert_eq!(format_file_size(1572864), "1.5 MB");
294
295        // Test gigabytes
296        assert_eq!(format_file_size(1073741824), "1.0 GB");
297
298        // Test terabytes
299        assert_eq!(format_file_size(1099511627776), "1.0 TB");
300    }
301
302    #[test]
303    fn test_get_filename_edge_cases() {
304        assert_eq!(get_filename("/path/to/file.log"), "file.log");
305        assert_eq!(get_filename("file.log"), "file.log");
306        assert_eq!(get_filename("/"), "unknown");
307        assert_eq!(get_filename(""), "unknown");
308        // For paths ending with /, the last component is actually "to", not "unknown"
309        assert_eq!(get_filename("/path/to/"), "to");
310    }
311
312    #[test]
313    fn test_is_symlink() {
314        // Test regular file
315        let temp_file = NamedTempFile::new().unwrap();
316        assert!(!is_symlink(temp_file.path()));
317
318        // Test nonexistent file
319        assert!(!is_symlink("/nonexistent/file.log"));
320    }
321
322    #[test]
323    fn test_resolve_symlink() {
324        // Test with regular file (not a symlink)
325        let temp_file = NamedTempFile::new().unwrap();
326        let result = resolve_symlink(temp_file.path());
327        assert!(result.is_err());
328        assert!(result
329            .unwrap_err()
330            .to_string()
331            .contains("Failed to read symlink"));
332
333        // Test with nonexistent file
334        let result = resolve_symlink("/nonexistent/file.log");
335        assert!(result.is_err());
336        assert!(result
337            .unwrap_err()
338            .to_string()
339            .contains("Failed to read symlink"));
340    }
341}