1use crate::error::{Error, Result};
4use crate::patterns::matcher::PatternMatcher;
5use crate::types::GroupByKey;
6use std::collections::HashMap;
7use std::path::Path;
8
9pub struct PatternGroup {
11 pub name: String,
13 pub matcher: PatternMatcher,
15}
16
17pub struct PatternLoader;
19
20impl PatternLoader {
21 pub fn load_from_file<'buf>(path: &str, buf: &'buf mut String) -> Result<Vec<&'buf str>> {
27 *buf = std::fs::read_to_string(path).map_err(|e| {
28 Error::Pattern(format!("Failed to read pattern file '{}': {}", path, e))
29 })?;
30
31 Ok(buf
32 .lines()
33 .map(|line| line.trim())
34 .filter(|line| !line.is_empty() && !line.starts_with('#'))
35 .collect())
36 }
37
38 pub fn load_yaml_groups(yaml: &str, negation_first: bool) -> Result<Vec<PatternGroup>> {
50 let groups: HashMap<String, Vec<String>> =
51 serde_norway::from_str(yaml).map_err(|e| Error::Yaml(e.to_string()))?;
52
53 let mut result = Vec::with_capacity(groups.len());
54
55 for (name, patterns) in groups {
56 let pattern_refs: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect();
57
58 let mut includes = Vec::new();
60 let mut excludes = Vec::new();
61
62 for pattern in &pattern_refs {
63 if let Some(stripped) = pattern.strip_prefix('!') {
64 excludes.push(stripped);
65 } else {
66 includes.push(*pattern);
67 }
68 }
69
70 let matcher = PatternMatcher::new(&includes, &excludes, negation_first)?;
71 result.push(PatternGroup { name, matcher });
72 }
73
74 Ok(result)
75 }
76
77 pub fn parse_group_by_template(template: &str) -> Result<GroupByTemplate<'_>> {
85 let marker = "{group}";
86 let first = template.find(marker).ok_or_else(|| {
87 Error::Config(format!(
88 "files_group_by template '{}' must contain '{{group}}'",
89 template
90 ))
91 })?;
92
93 if template[first + marker.len()..].contains(marker) {
95 return Err(Error::Config(format!(
96 "files_group_by template '{}' must contain exactly one '{{group}}'",
97 template
98 )));
99 }
100
101 let prefix = &template[..first];
102 let suffix = &template[first + marker.len()..];
103
104 let scan_dir = prefix.trim_end_matches('/');
106 let scan_dir = if scan_dir.is_empty() { "." } else { scan_dir };
107
108 Ok(GroupByTemplate {
109 prefix,
110 suffix,
111 scan_dir,
112 })
113 }
114
115 pub fn discover_groups_from_template(
126 template: &GroupByTemplate<'_>,
127 repo_root: &Path,
128 negation_first: bool,
129 key_mode: GroupByKey,
130 ) -> Result<Vec<PatternGroup>> {
131 let scan_path = repo_root.join(template.scan_dir);
132 if !scan_path.is_dir() {
133 return Err(Error::Config(format!(
134 "files_group_by scan directory '{}' does not exist",
135 template.scan_dir
136 )));
137 }
138
139 let needs_recursive = !template.suffix.contains("**");
140
141 let mut groups = Vec::new();
142
143 if needs_recursive {
144 Self::discover_groups_recursive(
145 &scan_path,
146 &scan_path,
147 template,
148 repo_root,
149 &mut groups,
150 negation_first,
151 key_mode,
152 )?;
153 } else {
154 Self::discover_groups_single_level(
155 &scan_path,
156 template,
157 &mut groups,
158 negation_first,
159 key_mode,
160 )?;
161 }
162
163 groups.sort_by(|a, b| a.name.cmp(&b.name));
165
166 Ok(groups)
167 }
168
169 fn discover_groups_single_level(
171 scan_path: &Path,
172 template: &GroupByTemplate<'_>,
173 groups: &mut Vec<PatternGroup>,
174 negation_first: bool,
175 key_mode: GroupByKey,
176 ) -> Result<()> {
177 let entries = std::fs::read_dir(scan_path).map_err(|e| {
178 Error::Config(format!(
179 "Failed to read directory '{}': {}",
180 scan_path.display(),
181 e
182 ))
183 })?;
184
185 for entry in entries {
186 let entry = entry
187 .map_err(|e| Error::Config(format!("Failed to read directory entry: {}", e)))?;
188
189 let ft = entry
190 .file_type()
191 .map_err(|e| Error::Config(format!("Failed to read file type: {}", e)))?;
192
193 if !ft.is_dir() {
194 continue;
195 }
196
197 let dir_name = entry.file_name();
198 let dir_name_str = dir_name.to_string_lossy();
199
200 if dir_name_str.starts_with('.') {
202 continue;
203 }
204
205 let pattern = format!("{}{}{}", template.prefix, dir_name_str, template.suffix);
207
208 let key = Self::build_group_key(&dir_name_str, &dir_name_str, template, key_mode);
209
210 let matcher = PatternMatcher::new(&[pattern.as_str()], &[], negation_first)?;
211 groups.push(PatternGroup { name: key, matcher });
212 }
213
214 Ok(())
215 }
216
217 fn discover_groups_recursive(
222 current: &Path,
223 scan_root: &Path,
224 template: &GroupByTemplate<'_>,
225 repo_root: &Path,
226 groups: &mut Vec<PatternGroup>,
227 negation_first: bool,
228 key_mode: GroupByKey,
229 ) -> Result<()> {
230 let entries = match std::fs::read_dir(current) {
231 Ok(e) => e,
232 Err(_) => return Ok(()),
233 };
234
235 for entry in entries {
236 let entry = entry
237 .map_err(|e| Error::Config(format!("Failed to read directory entry: {}", e)))?;
238
239 let ft = entry
240 .file_type()
241 .map_err(|e| Error::Config(format!("Failed to read file type: {}", e)))?;
242
243 if !ft.is_dir() {
244 continue;
245 }
246
247 let dir_name = entry.file_name();
248 let dir_name_str = dir_name.to_string_lossy();
249
250 if dir_name_str.starts_with('.') {
251 continue;
252 }
253
254 let dir_path = entry.path();
255 let relative = dir_path
256 .strip_prefix(scan_root)
257 .unwrap_or(dir_path.as_path());
258 let relative_lossy = relative.to_string_lossy();
261 let relative_str = crate::platform::PathUtil::to_posix(&relative_lossy);
262
263 let candidate = format!("{}{}{}", template.prefix, relative_str, template.suffix);
265 let candidate_path = repo_root.join(&candidate);
266
267 if candidate_path.exists() && !candidate_path.is_dir() {
268 let subtree_pattern = format!("{}{}/**", template.prefix, relative_str);
270
271 let key = Self::build_group_key(&relative_str, &relative_str, template, key_mode);
272
273 let matcher =
274 PatternMatcher::new(&[subtree_pattern.as_str()], &[], negation_first)?;
275 groups.push(PatternGroup { name: key, matcher });
276 }
277
278 Self::discover_groups_recursive(
280 &dir_path,
281 scan_root,
282 template,
283 repo_root,
284 groups,
285 negation_first,
286 key_mode,
287 )?;
288 }
289
290 Ok(())
291 }
292
293 pub fn synthesize_groups_for_paths<'p>(
299 template: &GroupByTemplate<'_>,
300 key_mode: GroupByKey,
301 paths: impl Iterator<Item = &'p str>,
302 negation_first: bool,
303 groups: &mut Vec<PatternGroup>,
304 ) -> Result<()> {
305 let single_level = template.suffix.contains("**");
306 for path in paths {
307 let Some(rel) = path.strip_prefix(template.prefix) else {
308 continue;
309 };
310 let (group_rel, pattern) = if single_level {
311 if !rel.contains('/') {
313 continue; }
315 let Some(first) = rel.split('/').next().filter(|c| !c.is_empty()) else {
316 continue;
317 };
318 (
319 first.to_string(),
320 format!("{}{}{}", template.prefix, first, template.suffix),
321 )
322 } else {
323 let Some(rel_dir) = rel.strip_suffix(template.suffix).filter(|d| !d.is_empty())
326 else {
327 continue;
328 };
329 (
330 rel_dir.to_string(),
331 format!("{}{}/**", template.prefix, rel_dir),
332 )
333 };
334 let key = Self::build_group_key(&group_rel, &group_rel, template, key_mode);
335 if groups.iter().any(|g| g.name == key) {
336 continue;
337 }
338 let matcher = PatternMatcher::new(&[pattern.as_str()], &[], negation_first)?;
339 groups.push(PatternGroup { name: key, matcher });
340 }
341 groups.sort_by(|a, b| a.name.cmp(&b.name));
342 Ok(())
343 }
344
345 fn build_group_key(
347 name: &str,
348 relative_path: &str,
349 template: &GroupByTemplate<'_>,
350 key_mode: GroupByKey,
351 ) -> String {
352 match key_mode {
353 GroupByKey::Name => name.to_string(),
354 GroupByKey::Path => {
355 if template.scan_dir == "." {
356 relative_path.to_string()
357 } else {
358 format!("{}/{}", template.scan_dir, relative_path)
359 }
360 }
361 GroupByKey::Hash => {
362 use std::collections::hash_map::DefaultHasher;
363 use std::hash::{Hash, Hasher};
364 let mut hasher = DefaultHasher::new();
365 relative_path.hash(&mut hasher);
366 format!("{:08x}", hasher.finish() as u32)
367 }
368 }
369 }
370}
371
372pub struct GroupByTemplate<'a> {
374 pub prefix: &'a str,
376 pub suffix: &'a str,
378 pub scan_dir: &'a str,
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use std::io::Write;
386 use tempfile::NamedTempFile;
387
388 #[test]
389 fn test_load_from_file() {
390 let mut file = NamedTempFile::new().unwrap();
391 writeln!(file, "# Comment line").unwrap();
392 writeln!(file, "**/*.rs").unwrap();
393 writeln!(file).unwrap();
394 writeln!(file, "src/**/*.ts").unwrap();
395 writeln!(file, " # Another comment ").unwrap();
396 writeln!(file, "docs/").unwrap();
397
398 let path = file.path().to_str().unwrap().to_string();
399 let mut buf = String::new();
400 let patterns = PatternLoader::load_from_file(&path, &mut buf).unwrap();
401
402 assert_eq!(patterns.len(), 3);
403 assert_eq!(patterns[0], "**/*.rs");
404 assert_eq!(patterns[1], "src/**/*.ts");
405 assert_eq!(patterns[2], "docs/");
406 }
407
408 #[test]
409 fn test_load_yaml_groups() {
410 let yaml = r#"
411frontend:
412 - "src/components/**"
413 - "src/pages/**"
414 - "!src/components/test/**"
415backend:
416 - "src/api/**"
417"#;
418
419 let groups = PatternLoader::load_yaml_groups(yaml, true).unwrap();
420 assert_eq!(groups.len(), 2);
421
422 let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
424 assert!(names.contains(&"frontend"));
425 assert!(names.contains(&"backend"));
426
427 let frontend = groups.iter().find(|g| g.name == "frontend").unwrap();
429 assert!(frontend.matcher.matches_sync("src/components/Button.tsx"));
430 assert!(!frontend
431 .matcher
432 .matches_sync("src/components/test/Button.test.tsx"));
433 assert!(!frontend.matcher.matches_sync("src/api/routes.ts"));
434 }
435
436 #[test]
437 fn test_load_yaml_invalid() {
438 let result = PatternLoader::load_yaml_groups("not: [valid: yaml", true);
439 assert!(result.is_err());
440 }
441
442 #[test]
445 fn test_parse_template_basic() {
446 let t = PatternLoader::parse_group_by_template("stacks/{group}/**").unwrap();
447 assert_eq!(t.prefix, "stacks/");
448 assert_eq!(t.suffix, "/**");
449 assert_eq!(t.scan_dir, "stacks");
450 }
451
452 #[test]
453 fn test_parse_template_nested() {
454 let t = PatternLoader::parse_group_by_template("infra/regions/{group}/config/**").unwrap();
455 assert_eq!(t.prefix, "infra/regions/");
456 assert_eq!(t.suffix, "/config/**");
457 assert_eq!(t.scan_dir, "infra/regions");
458 }
459
460 #[test]
461 fn test_parse_template_missing_placeholder() {
462 let result = PatternLoader::parse_group_by_template("stacks/**");
463 assert!(result.is_err());
464 }
465
466 #[test]
467 fn test_parse_template_duplicate_placeholder() {
468 let result = PatternLoader::parse_group_by_template("{group}/{group}/**");
469 assert!(result.is_err());
470 }
471
472 #[test]
473 fn test_discover_groups_name_mode() {
474 let dir = tempfile::tempdir().unwrap();
475 std::fs::create_dir(dir.path().join("stacks")).unwrap();
476 std::fs::create_dir(dir.path().join("stacks/dev")).unwrap();
477 std::fs::create_dir(dir.path().join("stacks/staging")).unwrap();
478 std::fs::create_dir(dir.path().join("stacks/prod")).unwrap();
479
480 let t = PatternLoader::parse_group_by_template("stacks/{group}/**").unwrap();
481 let groups =
482 PatternLoader::discover_groups_from_template(&t, dir.path(), true, GroupByKey::Name)
483 .unwrap();
484
485 assert_eq!(groups.len(), 3);
486 let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
487 assert!(names.contains(&"dev"));
488 assert!(names.contains(&"staging"));
489 assert!(names.contains(&"prod"));
490 }
491
492 #[test]
493 fn test_discover_groups_path_mode() {
494 let dir = tempfile::tempdir().unwrap();
495 std::fs::create_dir(dir.path().join("stacks")).unwrap();
496 std::fs::create_dir(dir.path().join("stacks/dev")).unwrap();
497 std::fs::create_dir(dir.path().join("stacks/prod")).unwrap();
498
499 let t = PatternLoader::parse_group_by_template("stacks/{group}/**").unwrap();
500 let groups =
501 PatternLoader::discover_groups_from_template(&t, dir.path(), true, GroupByKey::Path)
502 .unwrap();
503
504 assert_eq!(groups.len(), 2);
505 let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
506 assert!(names.contains(&"stacks/dev"));
507 assert!(names.contains(&"stacks/prod"));
508 }
509
510 #[test]
511 fn test_discover_groups_hash_mode() {
512 let dir = tempfile::tempdir().unwrap();
513 std::fs::create_dir(dir.path().join("stacks")).unwrap();
514 std::fs::create_dir(dir.path().join("stacks/prod")).unwrap();
515
516 let t = PatternLoader::parse_group_by_template("stacks/{group}/**").unwrap();
517 let groups =
518 PatternLoader::discover_groups_from_template(&t, dir.path(), true, GroupByKey::Hash)
519 .unwrap();
520
521 assert_eq!(groups.len(), 1);
522 assert_eq!(groups[0].name.len(), 8);
524 assert!(groups[0].name.chars().all(|c| c.is_ascii_hexdigit()));
525 }
526
527 #[test]
528 fn test_discover_groups_skips_hidden() {
529 let dir = tempfile::tempdir().unwrap();
530 std::fs::create_dir(dir.path().join("stacks")).unwrap();
531 std::fs::create_dir(dir.path().join("stacks/prod")).unwrap();
532 std::fs::create_dir(dir.path().join("stacks/.git")).unwrap();
533
534 let t = PatternLoader::parse_group_by_template("stacks/{group}/**").unwrap();
535 let groups =
536 PatternLoader::discover_groups_from_template(&t, dir.path(), true, GroupByKey::Name)
537 .unwrap();
538
539 assert_eq!(groups.len(), 1);
540 assert_eq!(groups[0].name, "prod");
541 }
542
543 #[test]
544 fn test_discover_groups_nonexistent_dir() {
545 let dir = tempfile::tempdir().unwrap();
546 let t = PatternLoader::parse_group_by_template("nonexistent/{group}/**").unwrap();
547 let result =
548 PatternLoader::discover_groups_from_template(&t, dir.path(), true, GroupByKey::Name);
549 assert!(result.is_err());
550 }
551
552 #[test]
553 fn test_discover_groups_pattern_matching() {
554 let dir = tempfile::tempdir().unwrap();
555 std::fs::create_dir(dir.path().join("stacks")).unwrap();
556 std::fs::create_dir(dir.path().join("stacks/dev")).unwrap();
557 std::fs::create_dir(dir.path().join("stacks/prod")).unwrap();
558
559 let t = PatternLoader::parse_group_by_template("stacks/{group}/**").unwrap();
560 let groups =
561 PatternLoader::discover_groups_from_template(&t, dir.path(), true, GroupByKey::Name)
562 .unwrap();
563
564 let dev = groups.iter().find(|g| g.name == "dev").unwrap();
565 assert!(dev.matcher.matches_sync("stacks/dev/config.yaml"));
566 assert!(!dev.matcher.matches_sync("stacks/prod/config.yaml"));
567
568 let prod = groups.iter().find(|g| g.name == "prod").unwrap();
569 assert!(prod.matcher.matches_sync("stacks/prod/config.yaml"));
570 assert!(!prod.matcher.matches_sync("stacks/dev/config.yaml"));
571 }
572
573 #[test]
574 fn test_yaml_groups_with_trailing_slash() {
575 let yaml = r#"
579infra:
580 - "terraform/"
581 - "stacks/prod/**"
582"#;
583
584 let groups = PatternLoader::load_yaml_groups(yaml, true).unwrap();
585 assert_eq!(groups.len(), 1);
586
587 let infra = groups.iter().find(|g| g.name == "infra").unwrap();
588 assert!(infra.matcher.matches_sync("stacks/prod/main.tf"));
590 assert!(infra.matcher.matches_sync("stacks/prod/modules/vpc.tf"));
591 assert!(!infra.matcher.matches_sync("stacks/dev/main.tf"));
594 }
595
596 #[test]
597 fn test_discover_groups_recursive_deeply_nested() {
598 let dir = tempfile::tempdir().unwrap();
599
600 let paths = [
602 "stacks/dev",
603 "stacks/prod",
604 "stacks/cloudsql/bq_to_cloudsql/gcs_to_sql",
605 "stacks/cloudsql/another_stack",
606 "stacks/networking/vpc",
607 ];
608 for p in &paths {
609 std::fs::create_dir_all(dir.path().join(p)).unwrap();
610 std::fs::write(dir.path().join(p).join("Pulumi.yaml"), "name: test").unwrap();
611 }
612
613 let t = PatternLoader::parse_group_by_template("stacks/{group}/Pulumi.yaml").unwrap();
614 assert!(!t.suffix.contains("**")); let groups =
617 PatternLoader::discover_groups_from_template(&t, dir.path(), true, GroupByKey::Name)
618 .unwrap();
619
620 let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
621 assert_eq!(groups.len(), 5);
622 assert!(names.contains(&"dev"));
623 assert!(names.contains(&"prod"));
624 assert!(names.contains(&"cloudsql/bq_to_cloudsql/gcs_to_sql"));
625 assert!(names.contains(&"cloudsql/another_stack"));
626 assert!(names.contains(&"networking/vpc"));
627 }
628
629 #[test]
630 fn test_discover_groups_recursive_pattern_matching() {
631 let dir = tempfile::tempdir().unwrap();
632 std::fs::create_dir_all(dir.path().join("stacks/a/b/c")).unwrap();
633 std::fs::write(dir.path().join("stacks/a/b/c/Pulumi.yaml"), "name: test").unwrap();
634 std::fs::create_dir_all(dir.path().join("stacks/dev")).unwrap();
635 std::fs::write(dir.path().join("stacks/dev/Pulumi.yaml"), "name: dev").unwrap();
636
637 let t = PatternLoader::parse_group_by_template("stacks/{group}/Pulumi.yaml").unwrap();
638 let groups =
639 PatternLoader::discover_groups_from_template(&t, dir.path(), true, GroupByKey::Name)
640 .unwrap();
641
642 let deep = groups.iter().find(|g| g.name == "a/b/c").unwrap();
644 assert!(deep.matcher.matches_sync("stacks/a/b/c/Pulumi.yaml"));
645 assert!(deep.matcher.matches_sync("stacks/a/b/c/index.ts"));
646 assert!(!deep.matcher.matches_sync("stacks/dev/Pulumi.yaml"));
647
648 let dev = groups.iter().find(|g| g.name == "dev").unwrap();
649 assert!(dev.matcher.matches_sync("stacks/dev/Pulumi.yaml"));
650 assert!(!dev.matcher.matches_sync("stacks/a/b/c/Pulumi.yaml"));
651 }
652
653 #[test]
654 fn test_discover_groups_recursive_path_mode() {
655 let dir = tempfile::tempdir().unwrap();
656 std::fs::create_dir_all(dir.path().join("stacks/a/b")).unwrap();
657 std::fs::write(dir.path().join("stacks/a/b/Pulumi.yaml"), "").unwrap();
658
659 let t = PatternLoader::parse_group_by_template("stacks/{group}/Pulumi.yaml").unwrap();
660 let groups =
661 PatternLoader::discover_groups_from_template(&t, dir.path(), true, GroupByKey::Path)
662 .unwrap();
663
664 assert_eq!(groups.len(), 1);
665 assert_eq!(groups[0].name, "stacks/a/b");
666 }
667
668 #[test]
669 fn test_discover_groups_recursive_skips_dirs_without_marker() {
670 let dir = tempfile::tempdir().unwrap();
671 std::fs::create_dir_all(dir.path().join("stacks/has_marker")).unwrap();
673 std::fs::write(dir.path().join("stacks/has_marker/Pulumi.yaml"), "").unwrap();
674 std::fs::create_dir_all(dir.path().join("stacks/no_marker")).unwrap();
675 std::fs::write(dir.path().join("stacks/no_marker/other.txt"), "").unwrap();
676
677 let t = PatternLoader::parse_group_by_template("stacks/{group}/Pulumi.yaml").unwrap();
678 let groups =
679 PatternLoader::discover_groups_from_template(&t, dir.path(), true, GroupByKey::Name)
680 .unwrap();
681
682 assert_eq!(groups.len(), 1);
683 assert_eq!(groups[0].name, "has_marker");
684 }
685
686 #[test]
687 fn test_discover_groups_star_star_suffix_stays_single_level() {
688 let dir = tempfile::tempdir().unwrap();
690 std::fs::create_dir_all(dir.path().join("stacks/dev/nested")).unwrap();
691 std::fs::create_dir_all(dir.path().join("stacks/prod")).unwrap();
692
693 let t = PatternLoader::parse_group_by_template("stacks/{group}/**").unwrap();
694 let groups =
695 PatternLoader::discover_groups_from_template(&t, dir.path(), true, GroupByKey::Name)
696 .unwrap();
697
698 let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect();
700 assert_eq!(groups.len(), 2);
701 assert!(names.contains(&"dev"));
702 assert!(names.contains(&"prod"));
703 }
704
705 #[test]
706 fn test_yaml_groups_exclude_pattern() {
707 let yaml = r#"
709app:
710 - "src/**"
711 - "!src/test/**"
712 - "!src/vendor/**"
713"#;
714
715 let groups = PatternLoader::load_yaml_groups(yaml, true).unwrap();
716 assert_eq!(groups.len(), 1);
717
718 let app = &groups[0];
719 assert_eq!(app.name, "app");
720
721 assert!(app.matcher.matches_sync("src/main.rs"));
723 assert!(app.matcher.matches_sync("src/lib/utils.rs"));
724
725 assert!(!app.matcher.matches_sync("src/test/unit_test.rs"));
727 assert!(!app.matcher.matches_sync("src/vendor/dep/lib.rs"));
728
729 assert!(!app.matcher.matches_sync("docs/README.md"));
731 }
732}