tl_cli/input/
reader.rs

1use anyhow::{Context, Result, bail};
2use std::fs;
3use std::io::{self, Read};
4
5/// Maximum input size (1 MB).
6const MAX_INPUT_SIZE: usize = 1024 * 1024;
7
8/// Reads input from files or stdin.
9///
10/// Enforces a maximum input size of 1 MB to prevent memory issues.
11pub struct InputReader;
12
13impl InputReader {
14    /// Reads input from a file or stdin.
15    ///
16    /// If `file_path` is `Some`, reads from the specified file.
17    /// If `file_path` is `None`, reads from stdin.
18    ///
19    /// # Errors
20    ///
21    /// Returns an error if:
22    /// - The file cannot be read
23    /// - The input exceeds 1 MB
24    pub fn read(file_path: Option<&str>) -> Result<String> {
25        file_path.map_or_else(Self::read_stdin, Self::read_file)
26    }
27
28    fn read_file(path: &str) -> Result<String> {
29        let metadata =
30            fs::metadata(path).with_context(|| format!("Failed to access file: {path}"))?;
31
32        let size = metadata.len() as usize;
33        if size > MAX_INPUT_SIZE {
34            bail!(
35                "Input size ({:.1} MB) exceeds maximum allowed size (1 MB).\n\n\
36                 Consider splitting the file into smaller parts.",
37                size as f64 / 1024.0 / 1024.0
38            );
39        }
40
41        fs::read_to_string(path).with_context(|| format!("Failed to read file: {path}"))
42    }
43
44    #[allow(clippy::significant_drop_tightening)]
45    fn read_stdin() -> Result<String> {
46        let mut buffer = Vec::new();
47        let mut chunk = [0u8; 8192];
48        let mut stdin = io::stdin().lock();
49
50        loop {
51            let bytes_read = stdin
52                .read(&mut chunk)
53                .context("Failed to read from stdin")?;
54
55            if bytes_read == 0 {
56                break;
57            }
58
59            buffer.extend_from_slice(&chunk[..bytes_read]);
60
61            if buffer.len() > MAX_INPUT_SIZE {
62                bail!(
63                    "Input size ({:.1} MB) exceeds maximum allowed size (1 MB).\n\n\
64                     Consider splitting the input into smaller parts.",
65                    buffer.len() as f64 / 1024.0 / 1024.0
66                );
67            }
68        }
69
70        String::from_utf8(buffer).context("Input is not valid UTF-8")
71    }
72}
73
74#[cfg(test)]
75#[allow(clippy::unwrap_used)]
76mod tests {
77    use super::*;
78    use std::io::Write;
79    use tempfile::{NamedTempFile, TempDir};
80
81    #[test]
82    fn test_read_file() {
83        let mut temp_file = NamedTempFile::new().unwrap();
84        writeln!(temp_file, "Hello, World!").unwrap();
85
86        let content = InputReader::read(Some(temp_file.path().to_str().unwrap())).unwrap();
87        assert_eq!(content.trim(), "Hello, World!");
88    }
89
90    #[test]
91    fn test_read_nonexistent_file() {
92        let result = InputReader::read(Some("/nonexistent/path/to/file.txt"));
93        assert!(result.is_err());
94    }
95
96    #[test]
97    fn test_max_input_size_constant() {
98        assert_eq!(MAX_INPUT_SIZE, 1024 * 1024);
99    }
100
101    #[test]
102    fn test_read_file_unicode() {
103        let mut temp_file = NamedTempFile::new().unwrap();
104        let content = "こんにちは世界!🌍\n日本語テスト";
105        write!(temp_file, "{content}").unwrap();
106
107        let result = InputReader::read(Some(temp_file.path().to_str().unwrap())).unwrap();
108        assert_eq!(result, content);
109    }
110
111    #[test]
112    fn test_read_empty_file() {
113        let temp_file = NamedTempFile::new().unwrap();
114
115        let content = InputReader::read(Some(temp_file.path().to_str().unwrap())).unwrap();
116        assert!(content.is_empty());
117    }
118
119    #[test]
120    fn test_read_file_exceeds_max_size() {
121        let temp_dir = TempDir::new().unwrap();
122        let file_path = temp_dir.path().join("large_file.txt");
123
124        // Create a file larger than MAX_INPUT_SIZE (1MB + 1 byte)
125        let large_content = "x".repeat(MAX_INPUT_SIZE + 1);
126        fs::write(&file_path, &large_content).unwrap();
127
128        let result = InputReader::read(Some(file_path.to_str().unwrap()));
129        assert!(result.is_err());
130        assert!(result.unwrap_err().to_string().contains("exceeds maximum"));
131    }
132
133    #[test]
134    fn test_read_file_at_max_size() {
135        let temp_dir = TempDir::new().unwrap();
136        let file_path = temp_dir.path().join("max_file.txt");
137
138        // Create a file exactly at MAX_INPUT_SIZE
139        let content = "x".repeat(MAX_INPUT_SIZE);
140        fs::write(&file_path, &content).unwrap();
141
142        let result = InputReader::read(Some(file_path.to_str().unwrap()));
143        assert!(result.is_ok());
144        assert_eq!(result.unwrap().len(), MAX_INPUT_SIZE);
145    }
146
147    #[test]
148    fn test_read_file_multiline() {
149        let mut temp_file = NamedTempFile::new().unwrap();
150        let content = "Line 1\nLine 2\nLine 3";
151        write!(temp_file, "{content}").unwrap();
152
153        let result = InputReader::read(Some(temp_file.path().to_str().unwrap())).unwrap();
154        assert_eq!(result, content);
155    }
156}