1use serde_json::Value;
2use std::fs;
3use std::path::{Path, PathBuf};
4use walkdir::WalkDir;
5
6use crate::entities::{GtsConfig, GtsEntity, GtsFile};
7use crate::store::GtsReader;
8
9const EXCLUDE_LIST: &[&str] = &["node_modules", "dist", "build"];
10const VALID_EXTENSIONS: &[&str] = &[".json", ".jsonc", ".gts", ".yaml", ".yml"];
11
12pub struct GtsFileReader {
13 paths: Vec<PathBuf>,
14 cfg: GtsConfig,
15 files: Vec<PathBuf>,
16 initialized: bool,
17}
18
19impl GtsFileReader {
20 #[must_use]
21 pub fn new(path: &[String], cfg: Option<GtsConfig>) -> Self {
22 let paths = path
23 .iter()
24 .map(|p| PathBuf::from(shellexpand::tilde(p).to_string()))
25 .collect();
26
27 GtsFileReader {
28 paths,
29 cfg: cfg.unwrap_or_default(),
30 files: Vec::new(),
31 initialized: false,
32 }
33 }
34
35 #[allow(clippy::cognitive_complexity)]
36 fn collect_files(&mut self) {
37 let mut seen = std::collections::HashSet::new();
38 let mut collected = Vec::new();
39
40 for path in &self.paths {
41 let resolved_path = path.canonicalize().unwrap_or_else(|_| path.clone());
42
43 if resolved_path.is_file() {
44 if let Some(ext) = resolved_path.extension() {
45 let ext_str = ext.to_string_lossy().to_lowercase();
46 if VALID_EXTENSIONS.contains(&format!(".{ext_str}").as_str()) {
47 let rp = resolved_path.to_string_lossy().to_string();
48 if !seen.contains(&rp) {
49 seen.insert(rp.clone());
50 tracing::debug!("- discovered file: {:?}", resolved_path);
51 collected.push(resolved_path.clone());
52 }
53 }
54 }
55 } else if resolved_path.is_dir() {
56 for entry in WalkDir::new(&resolved_path)
57 .follow_links(true)
58 .into_iter()
59 .flatten()
60 {
61 let path = entry.path();
62
63 if path.is_dir()
65 && let Some(name) = path.file_name()
66 && EXCLUDE_LIST.contains(&name.to_string_lossy().as_ref())
67 {
68 continue;
69 }
70
71 if path.is_file()
72 && let Some(ext) = path.extension()
73 {
74 let ext_str = ext.to_string_lossy().to_lowercase();
75 if VALID_EXTENSIONS.contains(&format!(".{ext_str}").as_str()) {
76 let rp = path
77 .canonicalize()
78 .unwrap_or_else(|_| path.to_path_buf())
79 .to_string_lossy()
80 .to_string();
81 if !seen.contains(&rp) {
82 seen.insert(rp.clone());
83 tracing::debug!("- discovered file: {:?}", path);
84 collected.push(PathBuf::from(rp));
85 }
86 }
87 }
88 }
89 }
90 }
91
92 self.files = collected;
93 }
94
95 fn load_json_file(file_path: &Path) -> Result<Value, Box<dyn std::error::Error>> {
96 let content = fs::read_to_string(file_path)?;
97
98 let extension = file_path
100 .extension()
101 .and_then(|e| e.to_str())
102 .map(str::to_lowercase)
103 .unwrap_or_default();
104
105 let value: Value = match extension.as_str() {
106 "yaml" | "yml" => {
107 serde_saphyr::from_str(&content)?
109 }
110 _ => {
111 serde_json::from_str(&content)?
113 }
114 };
115
116 Ok(value)
117 }
118
119 #[allow(clippy::cognitive_complexity)]
120 fn process_file(&self, file_path: &Path) -> Vec<GtsEntity> {
121 let mut entities = Vec::new();
122
123 match Self::load_json_file(file_path) {
124 Ok(content) => {
125 let json_file = GtsFile::new(
126 file_path.to_string_lossy().to_string(),
127 file_path
128 .file_name()
129 .unwrap_or_default()
130 .to_string_lossy()
131 .to_string(),
132 content.clone(),
133 );
134
135 if let Some(arr) = content.as_array() {
137 for (idx, item) in arr.iter().enumerate() {
138 let entity = GtsEntity::new(
139 Some(json_file.clone()),
140 Some(idx),
141 item,
142 Some(&self.cfg),
143 None,
144 false,
145 String::new(),
146 None,
147 None,
148 );
149 if let Some(id) = entity.effective_id() {
151 tracing::debug!("- discovered entity: {}", id);
152 entities.push(entity);
153 } else {
154 tracing::debug!("- skipped entity from {:?} (no valid ID)", file_path);
155 }
156 }
157 } else {
158 let entity = GtsEntity::new(
159 Some(json_file),
160 None,
161 &content,
162 Some(&self.cfg),
163 None,
164 false,
165 String::new(),
166 None,
167 None,
168 );
169 if let Some(id) = entity.effective_id() {
171 tracing::debug!("- discovered entity: {}", id);
172 entities.push(entity);
173 } else {
174 tracing::debug!(
175 "- skipped entity from {:?} (no valid ID found in content: {:?})",
176 file_path,
177 content
178 );
179 }
180 }
181 }
182 Err(e) => {
183 tracing::debug!("Failed to parse file {:?}: {}", file_path, e);
185 }
186 }
187
188 entities
189 }
190}
191
192impl GtsReader for GtsFileReader {
193 fn iter(&mut self) -> Box<dyn Iterator<Item = GtsEntity> + '_> {
194 if !self.initialized {
195 self.collect_files();
196 self.initialized = true;
197 }
198
199 tracing::debug!(
200 "Processing {} files from {:?}",
201 self.files.len(),
202 self.paths
203 );
204
205 #[allow(clippy::needless_collect)]
206 let entities: Vec<GtsEntity> = self
207 .files
208 .iter()
209 .flat_map(|file_path| self.process_file(file_path))
210 .collect();
211
212 Box::new(entities.into_iter())
213 }
214
215 fn read_by_id(&self, _entity_id: &str) -> Option<GtsEntity> {
216 None
218 }
219
220 fn reset(&mut self) {
221 self.initialized = false;
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use std::fs;
229 use tempfile::TempDir;
230
231 #[test]
232 fn test_new_with_default_config() {
233 let paths = vec!["/tmp/test".to_owned()];
234 let reader = GtsFileReader::new(&paths, None);
235
236 assert_eq!(reader.paths.len(), 1);
237 assert_eq!(reader.files.len(), 0);
238 assert!(!reader.initialized);
239 }
240
241 #[test]
242 fn test_new_with_custom_config() {
243 let paths = vec!["/tmp/test".to_owned()];
244 let config = GtsConfig::default();
245 let reader = GtsFileReader::new(&paths, Some(config));
246
247 assert_eq!(reader.paths.len(), 1);
248 assert!(!reader.initialized);
249 }
250
251 #[test]
252 fn test_new_with_tilde_expansion() {
253 let paths = vec!["~/test".to_owned()];
254 let reader = GtsFileReader::new(&paths, None);
255
256 assert!(!reader.paths[0].to_string_lossy().contains('~'));
258 }
259
260 #[test]
261 fn test_new_with_multiple_paths() {
262 let paths = vec![
263 "/tmp/test1".to_owned(),
264 "/tmp/test2".to_owned(),
265 "/tmp/test3".to_owned(),
266 ];
267 let reader = GtsFileReader::new(&paths, None);
268
269 assert_eq!(reader.paths.len(), 3);
270 }
271
272 #[test]
273 fn test_collect_files_all_supported_extensions() {
274 let temp_dir = TempDir::new().unwrap();
275 fs::write(temp_dir.path().join("test.json"), r#"{"$id": "test1"}"#).unwrap();
277 fs::write(temp_dir.path().join("test.yaml"), "id: test2").unwrap();
278 fs::write(temp_dir.path().join("test.yml"), "id: test3").unwrap();
279 fs::write(temp_dir.path().join("test.gts"), r#"{"$id": "test4"}"#).unwrap();
280 fs::write(temp_dir.path().join("test.jsonc"), r#"{"$id": "test5"}"#).unwrap();
281
282 let paths = vec![temp_dir.path().to_string_lossy().to_string()];
283 let mut reader = GtsFileReader::new(&paths, None);
284 reader.collect_files();
285
286 assert_eq!(
287 reader.files.len(),
288 5,
289 "Should collect all 5 supported file types"
290 );
291 }
292
293 #[test]
294 fn test_collect_files_invalid_extension_ignored() {
295 let temp_dir = TempDir::new().unwrap();
296 let file_path = temp_dir.path().join("test.txt");
297 fs::write(&file_path, "test content").unwrap();
298
299 let paths = vec![file_path.to_string_lossy().to_string()];
300 let mut reader = GtsFileReader::new(&paths, None);
301 reader.collect_files();
302
303 assert_eq!(reader.files.len(), 0);
304 }
305
306 #[test]
307 fn test_collect_files_directory() {
308 let temp_dir = TempDir::new().unwrap();
309 fs::write(temp_dir.path().join("file1.json"), r#"{"$id": "test1"}"#).unwrap();
310 fs::write(temp_dir.path().join("file2.json"), r#"{"$id": "test2"}"#).unwrap();
311 fs::write(temp_dir.path().join("file3.txt"), "ignored").unwrap();
312
313 let paths = vec![temp_dir.path().to_string_lossy().to_string()];
314 let mut reader = GtsFileReader::new(&paths, None);
315 reader.collect_files();
316
317 assert_eq!(reader.files.len(), 2);
318 }
319
320 #[test]
321 fn test_collect_files_excludes_standard_directories() {
322 let temp_dir = TempDir::new().unwrap();
323 fs::write(temp_dir.path().join("file1.json"), r#"{"$id": "test1"}"#).unwrap();
324
325 let node_modules = temp_dir.path().join("node_modules");
327 fs::create_dir(&node_modules).unwrap();
328 fs::write(node_modules.join("ignored1.json"), r#"{"$id": "ignored1"}"#).unwrap();
329
330 let dist = temp_dir.path().join("dist");
331 fs::create_dir(&dist).unwrap();
332 fs::write(dist.join("ignored2.json"), r#"{"$id": "ignored2"}"#).unwrap();
333
334 let build = temp_dir.path().join("build");
335 fs::create_dir(&build).unwrap();
336 fs::write(build.join("ignored3.json"), r#"{"$id": "ignored3"}"#).unwrap();
337
338 let paths = vec![temp_dir.path().to_string_lossy().to_string()];
339 let mut reader = GtsFileReader::new(&paths, None);
340 reader.collect_files();
341
342 assert!(
344 !reader.files.is_empty(),
345 "Should find at least the main file"
346 );
347
348 let main_file_found = reader.files.iter().any(|f| {
352 let path_str = f.to_string_lossy();
353 path_str.ends_with("file1.json")
354 });
355 assert!(main_file_found, "Should find the main file");
356 }
357
358 #[test]
359 fn test_collect_files_case_insensitive_extension() {
360 let temp_dir = TempDir::new().unwrap();
361 fs::write(temp_dir.path().join("test.JSON"), r#"{"$id": "test"}"#).unwrap();
362 fs::write(temp_dir.path().join("test2.YML"), "id: test2").unwrap();
363
364 let paths = vec![temp_dir.path().to_string_lossy().to_string()];
365 let mut reader = GtsFileReader::new(&paths, None);
366 reader.collect_files();
367
368 assert_eq!(reader.files.len(), 2);
369 }
370
371 #[test]
372 fn test_collect_files_deduplicates() {
373 let temp_dir = TempDir::new().unwrap();
374 let file_path = temp_dir.path().join("test.json");
375 fs::write(&file_path, r#"{"$id": "test"}"#).unwrap();
376
377 let path_str = file_path.to_string_lossy().to_string();
378 let paths = vec![path_str.clone(), path_str];
379 let mut reader = GtsFileReader::new(&paths, None);
380 reader.collect_files();
381
382 assert_eq!(reader.files.len(), 1);
383 }
384
385 #[test]
386 fn test_collect_files_nested_directories() {
387 let temp_dir = TempDir::new().unwrap();
388 let nested = temp_dir.path().join("level1").join("level2");
389 fs::create_dir_all(&nested).unwrap();
390 fs::write(nested.join("nested.json"), r#"{"$id": "nested"}"#).unwrap();
391
392 let paths = vec![temp_dir.path().to_string_lossy().to_string()];
393 let mut reader = GtsFileReader::new(&paths, None);
394 reader.collect_files();
395
396 assert_eq!(reader.files.len(), 1);
397 }
398
399 #[test]
400 fn test_load_json_file_valid_json() {
401 let temp_dir = TempDir::new().unwrap();
402 let file_path = temp_dir.path().join("test.json");
403 fs::write(&file_path, r#"{"name": "test", "value": 42}"#).unwrap();
404
405 let result = GtsFileReader::load_json_file(&file_path);
406 assert!(result.is_ok());
407
408 let json = result.unwrap();
409 assert_eq!(json["name"], "test");
410 assert_eq!(json["value"], 42);
411 }
412
413 #[test]
414 fn test_load_json_file_yaml_extensions() {
415 let temp_dir = TempDir::new().unwrap();
416
417 let yaml_path = temp_dir.path().join("test.yaml");
419 fs::write(&yaml_path, "name: test\nvalue: 42").unwrap();
420 let yaml_result = GtsFileReader::load_json_file(&yaml_path);
421 assert!(yaml_result.is_ok());
422 assert_eq!(yaml_result.unwrap()["name"], "test");
423
424 let yml_path = temp_dir.path().join("test.yml");
426 fs::write(&yml_path, "name: test2\nvalue: 43").unwrap();
427 let yaml_result_yml = GtsFileReader::load_json_file(&yml_path);
428 assert!(yaml_result_yml.is_ok());
429 assert_eq!(yaml_result_yml.unwrap()["name"], "test2");
430 }
431
432 #[test]
433 fn test_load_json_file_invalid_json() {
434 let temp_dir = TempDir::new().unwrap();
435 let file_path = temp_dir.path().join("test.json");
436 fs::write(&file_path, "{invalid json}").unwrap();
437
438 let result = GtsFileReader::load_json_file(&file_path);
439 assert!(result.is_err());
440 }
441
442 #[test]
443 fn test_load_json_file_invalid_yaml() {
444 let temp_dir = TempDir::new().unwrap();
445 let file_path = temp_dir.path().join("test.yaml");
446 fs::write(&file_path, "invalid: yaml: content: [").unwrap();
447
448 let result = GtsFileReader::load_json_file(&file_path);
449 assert!(result.is_err());
450 }
451
452 #[test]
453 fn test_load_json_file_nonexistent() {
454 let result = GtsFileReader::load_json_file(Path::new("/nonexistent/file.json"));
455 assert!(result.is_err());
456 }
457
458 #[test]
459 fn test_process_file_single_entity() {
460 let temp_dir = TempDir::new().unwrap();
461 let file_path = temp_dir.path().join("test.json");
462 fs::write(&file_path, r#"{"$id": "gts://test/schema"}"#).unwrap();
463
464 let reader = GtsFileReader::new(&[], None);
465 let entities = reader.process_file(&file_path);
466
467 assert_eq!(entities.len(), 1);
468 }
469
470 #[test]
471 fn test_process_file_array_of_entities() {
472 let temp_dir = TempDir::new().unwrap();
473 let file_path = temp_dir.path().join("test.json");
474 fs::write(
475 &file_path,
476 r#"[{"$id": "gts://test/schema1"}, {"$id": "gts://test/schema2"}]"#,
477 )
478 .unwrap();
479
480 let reader = GtsFileReader::new(&[], None);
481 let entities = reader.process_file(&file_path);
482
483 assert_eq!(entities.len(), 2);
484 }
485
486 #[test]
487 fn test_process_file_entity_without_explicit_gts_id() {
488 let temp_dir = TempDir::new().unwrap();
489 let file_path = temp_dir.path().join("test.json");
490 fs::write(&file_path, r#"{"name": "test"}"#).unwrap();
491
492 let reader = GtsFileReader::new(&[], None);
493 let entities = reader.process_file(&file_path);
494
495 assert!(entities.len() <= 1);
499 }
500
501 #[test]
502 fn test_process_file_invalid_json_returns_empty() {
503 let temp_dir = TempDir::new().unwrap();
504 let file_path = temp_dir.path().join("test.json");
505 fs::write(&file_path, "{invalid}").unwrap();
506
507 let reader = GtsFileReader::new(&[], None);
508 let entities = reader.process_file(&file_path);
509
510 assert_eq!(entities.len(), 0);
511 }
512
513 #[test]
514 fn test_process_file_array_with_some_without_explicit_ids() {
515 let temp_dir = TempDir::new().unwrap();
516 let file_path = temp_dir.path().join("test.json");
517 fs::write(
518 &file_path,
519 r#"[{"$id": "gts://test/schema"}, {"name": "no-id"}]"#,
520 )
521 .unwrap();
522
523 let reader = GtsFileReader::new(&[], None);
524 let entities = reader.process_file(&file_path);
525
526 assert!(
528 !entities.is_empty(),
529 "Should find at least the entity with $id"
530 );
531 }
532
533 #[test]
534 fn test_iter_initialization_behavior() {
535 let temp_dir = TempDir::new().unwrap();
536 fs::write(
537 temp_dir.path().join("test.json"),
538 r#"{"$id": "gts://test/schema"}"#,
539 )
540 .unwrap();
541
542 let paths = vec![temp_dir.path().to_string_lossy().to_string()];
543 let mut reader = GtsFileReader::new(&paths, None);
544
545 assert!(!reader.initialized);
547
548 let entities: Vec<_> = reader.iter().collect();
550 assert!(reader.initialized);
551 assert_eq!(entities.len(), 1);
552
553 let entities2: Vec<_> = reader.iter().collect();
555 assert!(reader.initialized);
556 assert_eq!(entities2.len(), 1);
557 }
558
559 #[test]
560 fn test_iter_with_multiple_files() {
561 let temp_dir = TempDir::new().unwrap();
562 fs::write(
563 temp_dir.path().join("test1.json"),
564 r#"{"$id": "gts://test/schema1"}"#,
565 )
566 .unwrap();
567 fs::write(
568 temp_dir.path().join("test2.json"),
569 r#"{"$id": "gts://test/schema2"}"#,
570 )
571 .unwrap();
572
573 let paths = vec![temp_dir.path().to_string_lossy().to_string()];
574 let mut reader = GtsFileReader::new(&paths, None);
575
576 let entities: Vec<_> = reader.iter().collect();
577
578 assert_eq!(entities.len(), 2);
579 }
580
581 #[test]
582 fn test_read_by_id_always_returns_none() {
583 let reader = GtsFileReader::new(&[], None);
584
585 let result = reader.read_by_id("gts://test/schema");
586
587 assert!(result.is_none());
588 }
589
590 #[test]
591 fn test_reset_clears_initialized_flag() {
592 let temp_dir = TempDir::new().unwrap();
593 fs::write(
594 temp_dir.path().join("test.json"),
595 r#"{"$id": "gts://test/schema"}"#,
596 )
597 .unwrap();
598
599 let paths = vec![temp_dir.path().to_string_lossy().to_string()];
600 let mut reader = GtsFileReader::new(&paths, None);
601
602 let _: Vec<_> = reader.iter().collect();
603 assert!(reader.initialized);
604
605 reader.reset();
606 assert!(!reader.initialized);
607 }
608
609 #[test]
610 fn test_reset_allows_reinitialization() {
611 let temp_dir = TempDir::new().unwrap();
612 fs::write(
613 temp_dir.path().join("test.json"),
614 r#"{"$id": "gts://test/schema"}"#,
615 )
616 .unwrap();
617
618 let paths = vec![temp_dir.path().to_string_lossy().to_string()];
619 let mut reader = GtsFileReader::new(&paths, None);
620
621 let _: Vec<_> = reader.iter().collect();
622 reader.reset();
623
624 let entities: Vec<_> = reader.iter().collect();
625 assert_eq!(entities.len(), 1);
626 assert!(reader.initialized);
627 }
628}