1use std::fs;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6
7use crate::language::{Language, LanguageExt};
8
9pub mod custom_parsers;
10
11pub const MAX_FILE_SIZE: u64 = 1024 * 500;
12
13#[derive(Clone, PartialEq, Eq, Debug)]
15#[non_exhaustive]
16pub struct Content {
17 pub bytes: Vec<u8>,
19 pub text: String,
21 pub language: Language,
23 pub path: PathBuf,
25}
26
27enum LoadError {
28 TooLarge,
29 Io,
30}
31
32fn read_within_size_limit(path: &Path, max_size: u64) -> Result<Vec<u8>, LoadError> {
33 let file = fs::File::open(path).map_err(|_| LoadError::Io)?;
34
35 if file.metadata().map_err(|_| LoadError::Io)?.len() > max_size {
36 return Err(LoadError::TooLarge);
37 }
38
39 let mut buf = Vec::new();
40 file.take(max_size.saturating_add(1))
41 .read_to_end(&mut buf)
42 .map_err(|_| LoadError::Io)?;
43
44 if u64::try_from(buf.len()).unwrap_or(u64::MAX) > max_size {
45 return Err(LoadError::TooLarge);
46 }
47
48 Ok(buf)
49}
50
51impl Content {
52 #[must_use]
58 pub fn from_path(path: &Path, max_size: Option<u64>) -> Option<Self> {
59 let max_size = max_size.unwrap_or(MAX_FILE_SIZE);
60
61 let Some(language) = Language::from_path(path) else {
62 tracing::warn!(path = %path.display(), "skipping file with unsupported extension");
63 return None;
64 };
65
66 let bytes = match read_within_size_limit(path, max_size) {
67 Ok(bytes) => bytes,
68 Err(LoadError::TooLarge) => {
69 tracing::warn!(path = %path.display(), "file too large, ignoring");
70 return None;
71 }
72 Err(LoadError::Io) => {
73 tracing::warn!(path = %path.display(), "unable to read file");
74 return None;
75 }
76 };
77
78 let text = String::from_utf8_lossy(&bytes).into_owned();
79
80 let content = Self {
81 bytes,
82 text,
83 language,
84 path: path.to_path_buf(),
85 };
86
87 custom_parsers::for_language(language)
88 .filter(|cp| cp.validate(&content))
89 .find_map(|cp| cp.transform(&content))
90 .or(Some(content))
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::Content;
97 use crate::language::Language;
98 use std::fs;
99 use std::path::{Path, PathBuf};
100
101 const HELM_JSON: &str = concat!(
102 r#"{"apiVersion": "v1", "kind": "Deployment", "metadata": {"name": "test"}, "#,
103 r#""spec": {"replicas": {{ .Values.replicas }}}}"#
104 );
105
106 const HELM_YAML: &str = concat!(
107 "apiVersion: v1\n",
108 "kind: Deployment\n",
109 "metadata:\n",
110 " name: test\n",
111 "spec:\n",
112 " replicas: {{ .Values.replicas }}\n",
113 );
114
115 fn write_temp(name: &str, bytes: &[u8]) -> (tempfile::TempDir, PathBuf) {
116 let dir = tempfile::tempdir().unwrap();
117 let path = dir.path().join(name);
118 fs::write(&path, bytes).unwrap();
119 (dir, path)
120 }
121
122 #[test]
123 fn loads_supported_file() {
124 let (_dir, path) = write_temp("module.java", b"class A {}");
125 let content = Content::from_path(&path, None).unwrap();
126
127 assert_eq!(content.language, Language::Java);
128 assert_eq!(content.text, "class A {}");
129 assert_eq!(content.bytes, b"class A {}".to_vec());
130 assert_eq!(content.path, path);
131 }
132
133 #[test]
134 fn loads_empty_file() {
135 let (_dir, path) = write_temp("empty.java", b"");
136 let content = Content::from_path(&path, None).unwrap();
137
138 assert_eq!(content.text, "");
139 assert!(content.bytes.is_empty());
140 }
141
142 #[test]
143 fn loads_multiline_content() {
144 let (_dir, path) = write_temp("multi.java", b"line1\nline2\nline3\n");
145 let content = Content::from_path(&path, None).unwrap();
146
147 assert_eq!(content.text, "line1\nline2\nline3\n");
148 }
149
150 #[test]
151 fn loads_special_characters() {
152 let source = "print('ñáéíóú中文🚀')";
153 let (_dir, path) = write_temp("special.py", source.as_bytes());
154 let content = Content::from_path(&path, None).unwrap();
155
156 assert_eq!(content.text, source);
157 assert_eq!(content.bytes, source.as_bytes().to_vec());
158 }
159
160 #[test]
161 fn rejects_unsupported_extension() {
162 let (_dir, path) = write_temp("a.unknown", b"whatever");
163 assert!(Content::from_path(&path, None).is_none());
164 }
165
166 #[test]
167 fn rejects_missing_file() {
168 let dir = tempfile::tempdir().unwrap();
169 let path = dir.path().join("missing.java");
170 assert!(Content::from_path(&path, None).is_none());
171 }
172
173 #[test]
174 fn respects_custom_max_size() {
175 let (_dir, path) = write_temp("a.java", b"class A {}");
176
177 assert!(Content::from_path(&path, Some(4)).is_none());
178 assert!(Content::from_path(&path, Some(1024)).is_some());
179 }
180
181 #[test]
182 fn json_without_helm_template_is_unchanged() {
183 let (_dir, path) = write_temp("config.json", br#"{"key": "value"}"#);
184 let content = Content::from_path(&path, None).unwrap();
185
186 assert_eq!(content.language, Language::Json);
187 assert_eq!(content.text, r#"{"key": "value"}"#);
188 }
189
190 #[test]
191 fn yaml_without_helm_template_is_unchanged() {
192 let (_dir, path) = write_temp("config.yaml", b"key: value\nother: data");
193 let content = Content::from_path(&path, None).unwrap();
194
195 assert_eq!(content.language, Language::Yaml);
196 assert_eq!(content.text, "key: value\nother: data");
197 }
198
199 #[test]
200 fn json_helm_template_outside_templates_dir_is_unchanged() {
201 let (_dir, path) = write_temp("deployment.json", HELM_JSON.as_bytes());
202 let content = Content::from_path(&path, None).unwrap();
203
204 assert_eq!(content.language, Language::Json);
205 assert_eq!(content.text, HELM_JSON);
206 }
207
208 #[test]
209 fn yaml_helm_template_outside_templates_dir_is_unchanged() {
210 let (_dir, path) = write_temp("deployment.yaml", HELM_YAML.as_bytes());
211 let content = Content::from_path(&path, None).unwrap();
212
213 assert_eq!(content.language, Language::Yaml);
214 assert_eq!(content.text, HELM_YAML);
215 }
216
217 #[test]
218 fn json_helm_template_in_templates_dir_is_unchanged() {
219 let dir = tempfile::tempdir().unwrap();
220 let templates = dir.path().join("templates");
221 fs::create_dir(&templates).unwrap();
222 let path = templates.join("deployment.json");
223 fs::write(&path, HELM_JSON).unwrap();
224
225 let content = Content::from_path(&path, None).unwrap();
226
227 assert_eq!(content.language, Language::Json);
228 assert_eq!(content.text, HELM_JSON);
229 }
230
231 #[test]
232 fn yaml_helm_template_in_templates_dir_is_unchanged() {
233 let dir = tempfile::tempdir().unwrap();
234 let templates = dir.path().join("templates");
235 fs::create_dir(&templates).unwrap();
236 let path = templates.join("deployment.yaml");
237 fs::write(&path, HELM_YAML).unwrap();
238
239 let content = Content::from_path(&path, None).unwrap();
240
241 assert_eq!(content.language, Language::Yaml);
242 assert_eq!(content.text, HELM_YAML);
243 }
244
245 #[test]
246 fn fixed_output_matches_python_results() {
247 let base = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/data/helm_parser");
248 let outputs = base.join("outputs");
249 fs::create_dir_all(&outputs).unwrap();
250
251 let mut entries: Vec<_> = fs::read_dir(base.join("templates"))
252 .unwrap()
253 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
254 .filter(|path| path.is_file())
255 .collect();
256 entries.sort();
257
258 let mut mismatches: Vec<String> = Vec::new();
259 for path in entries {
260 let name = path.file_name().unwrap().to_string_lossy().into_owned();
261 let rust = Content::from_path(&path, None).unwrap().text;
262 fs::write(outputs.join(&name), &rust).unwrap();
263
264 let expected = fs::read_to_string(base.join("results").join(&name)).unwrap();
265 if rust != expected {
266 mismatches.push(name);
267 }
268 }
269
270 assert!(
271 mismatches.is_empty(),
272 "rust fixed output diverges from python results for: {mismatches:?}"
273 );
274 }
275}