Skip to main content

lechange_core/patterns/
loader.rs

1//! Unified pattern loading from source files and YAML
2
3use crate::error::{Error, Result};
4use crate::patterns::matcher::PatternMatcher;
5use crate::types::GroupByKey;
6use std::collections::HashMap;
7use std::path::Path;
8
9/// A named pattern group loaded from YAML
10pub struct PatternGroup {
11    /// Group name (YAML key)
12    pub name: String,
13    /// Compiled pattern matcher for this group
14    pub matcher: PatternMatcher,
15}
16
17/// Unified pattern loader for source files and YAML
18pub struct PatternLoader;
19
20impl PatternLoader {
21    /// Load patterns from a source file (one pattern per line)
22    ///
23    /// Lines starting with `#` are comments and are skipped.
24    /// Empty lines are skipped.
25    /// Trailing `/` is transformed to `/**`.
26    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    /// Load YAML pattern groups
39    ///
40    /// YAML format:
41    /// ```yaml
42    /// frontend:
43    ///   - src/components/**
44    ///   - src/pages/**
45    /// backend:
46    ///   - src/api/**
47    ///   - src/models/**
48    /// ```
49    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            // Separate include and exclude patterns (exclude starts with !)
59            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    /// Parse a `files_group_by` template string.
78    ///
79    /// Template must contain exactly one `{group}` placeholder.
80    /// Returns prefix (before `{group}`), suffix (after `{group}`), and the
81    /// scan directory (prefix without trailing `/`).
82    ///
83    /// Example: `"stacks/{group}/**"` → prefix=`"stacks/"`, suffix=`"/**"`, scan_dir=`"stacks"`
84    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        // Check for duplicate
94        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        // scan_dir = prefix without trailing separator
105        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    /// Discover groups from a template by scanning the filesystem.
116    ///
117    /// When the template suffix contains `**` (e.g. `stacks/{group}/**`), scans
118    /// only direct children of `scan_dir` — each top-level directory becomes a group.
119    ///
120    /// When the suffix does NOT contain `**` (e.g. `stacks/{group}/Pulumi.yaml`),
121    /// recursively walks the directory tree to find all paths where the suffix matches
122    /// an existing file. This allows `{group}` to capture multi-segment paths like
123    /// `cloudsql/bq_to_cloudsql/gcs_to_sql`. Each discovered directory becomes a group
124    /// with a `/**` pattern covering its entire subtree.
125    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        // Sort by name for deterministic order
164        groups.sort_by(|a, b| a.name.cmp(&b.name));
165
166        Ok(groups)
167    }
168
169    /// Single-level directory scan (original behavior for `**` suffixes).
170    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            // Skip hidden directories
201            if dir_name_str.starts_with('.') {
202                continue;
203            }
204
205            // Build the pattern: prefix + dir_name + suffix
206            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    /// Recursive directory walk for suffix patterns without `**`.
218    ///
219    /// Finds all directories under `scan_root` where `prefix + relative_path + suffix`
220    /// matches an existing file. Each match becomes a group with a `/**` subtree pattern.
221    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            // Windows: strip_prefix yields backslash separators, which would
259            // corrupt both the glob pattern and the group key. Normalize.
260            let relative_lossy = relative.to_string_lossy();
261            let relative_str = crate::platform::PathUtil::to_posix(&relative_lossy);
262
263            // Check if prefix + relative + suffix exists as a file
264            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                // Found a match — create a group covering the entire subtree
269                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            // Continue recursing into subdirectories
279            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    /// Synthesize groups for template-matching paths whose directories no
294    /// longer exist on disk (vanished or fully-deleted stacks). Filesystem
295    /// discovery cannot see them, so Destroy deploy entries would otherwise
296    /// never appear. Appends only groups whose key is not already present;
297    /// re-sorts for deterministic order.
298    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                // e.g. template `stacks/{group}/**`: group = first component
312                if !rel.contains('/') {
313                    continue; // file sits at the scan root, not in a group dir
314                }
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                // e.g. template `stacks/{group}/Pulumi.yaml`: path must equal
324                // prefix + rel_dir + suffix; group covers the subtree
325                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    /// Build a group key based on the key mode.
346    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
372/// Parsed `files_group_by` template
373pub struct GroupByTemplate<'a> {
374    /// Text before `{group}` (e.g. `"stacks/"`)
375    pub prefix: &'a str,
376    /// Text after `{group}` (e.g. `"/**"`)
377    pub suffix: &'a str,
378    /// Directory to scan for groups (prefix without trailing `/`)
379    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        // Verify names exist (order may vary due to HashMap)
423        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        // Verify matching works
428        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    // --- files_group_by template tests ---
443
444    #[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        // Hash keys are 8-char hex strings
523        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        // Patterns with trailing slashes should still work correctly.
576        // The YAML loader passes patterns directly to PatternMatcher which
577        // handles glob matching (trailing slash is a valid glob component).
578        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        // "stacks/prod/**" should match nested files
589        assert!(infra.matcher.matches_sync("stacks/prod/main.tf"));
590        assert!(infra.matcher.matches_sync("stacks/prod/modules/vpc.tf"));
591        // "terraform/" as a glob — it matches the literal directory name
592        // (glob behavior: trailing slash matches directory entries)
593        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        // Create deeply nested stacks with Pulumi.yaml marker files
601        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("**")); // triggers recursive mode
615
616        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        // Groups should match files within their subtree
643        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        // Only some directories have the marker file
672        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        // Backward compatibility: ** in suffix uses single-level scan
689        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        // Should only find dev and prod (direct children), NOT dev/nested
699        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        // Verify ! exclude patterns correctly exclude files from matching
708        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        // Included paths should match
722        assert!(app.matcher.matches_sync("src/main.rs"));
723        assert!(app.matcher.matches_sync("src/lib/utils.rs"));
724
725        // Excluded paths should NOT match
726        assert!(!app.matcher.matches_sync("src/test/unit_test.rs"));
727        assert!(!app.matcher.matches_sync("src/vendor/dep/lib.rs"));
728
729        // Paths outside src should NOT match
730        assert!(!app.matcher.matches_sync("docs/README.md"));
731    }
732}