Skip to main content

gdtest_analysis/
lib.rs

1//! Discovery helpers for gdtest.
2//!
3//! Two layers:
4//! 1. [`resolve`] turns user-supplied paths (files, directories, `res://…`)
5//!    into a flat list of `res://…` paths, filtered by discovery include/exclude globs.
6//! 2. [`list_tests_in`] parses one GDScript file with gdsyntax
7//!    and returns every `func test_*()` name in source order.
8//!
9//! Default patterns:
10//!   - `test/**/*.gd`  — any `.gd` file inside a `test/` directory tree
11//!   - `**/*.test.gd`  — co-located test files anywhere in the project
12//!
13//! Users can override via `gdtest.toml`:  `include = ["test/**/*.gd", "**/*.test.gd"]`
14//!
15//! Target: discovering 500 tests across 50 files in <100 ms on a warm cache
16//!.
17
18/// Default discovery globs used when no `include` key is in `gdtest.toml`.
19pub const DEFAULT_PATTERNS: &[&str] = &["test/**/*.gd", "**/*.test.gd"];
20
21pub mod coverage;
22pub mod deps;
23pub mod gdunit4_migrate;
24pub mod gut_migrate;
25pub mod mutate;
26pub mod rewrite;
27pub use gdunit4_migrate::{
28    GdUnit4MigrationAction, GdUnit4MigrationIssue, GdUnit4MigrationOutput, GdUnit4MigrationStatus,
29    is_gdunit4_test_file, is_gdunit4_test_source, migrate_gdunit4_file, migrate_gdunit4_source,
30};
31pub use gut_migrate::{
32    GutMigrationAction, GutMigrationIssue, GutMigrationOutput, GutMigrationStatus,
33    is_gut_test_file, is_gut_test_source, migrate_gut_file, migrate_gut_source,
34};
35pub use rewrite::{RewriteOutput, rewrite_file, rewrite_source};
36
37use std::path::{Path, PathBuf};
38
39use anyhow::{Context, Result, anyhow};
40use gdsyntax::ast::{AstNode, Member};
41use walkdir::WalkDir;
42
43/// Normalise every input path into a list of `res://…` test-file paths.
44///
45/// `patterns` is a slice of glob strings (relative to project root) used when
46/// walking directories. `exclude_patterns` is a slice of discovery-exclude
47/// globs, also relative to project root. Pass [`DEFAULT_PATTERNS`] and `[]`
48/// when no custom config is set.
49pub fn resolve(
50    inputs: &[String],
51    project_path: &Path,
52    patterns: &[String],
53    exclude_patterns: &[String],
54) -> Result<Vec<String>> {
55    let project_abs = project_path
56        .canonicalize()
57        .with_context(|| format!("project path does not exist: {}", project_path.display()))?;
58    let mut out: Vec<String> = Vec::new();
59    for input in inputs {
60        if let Some(res) = input.strip_prefix("res://") {
61            out.push(format!("res://{res}"));
62            continue;
63        }
64        let path = PathBuf::from(input);
65        let abs = if path.is_absolute() {
66            path.clone()
67        } else {
68            project_abs.join(&path)
69        };
70        // Canonicalize so the \\?\ UNC prefix (Windows) matches project_abs.
71        let abs = abs.canonicalize().unwrap_or(abs);
72        if abs.is_file() {
73            if abs.extension().and_then(|e| e.to_str()) != Some("gd") {
74                return Err(anyhow!("not a .gd file: {}", abs.display()));
75            }
76            out.push(to_res_path(&abs, &project_abs)?);
77        } else if abs.is_dir() {
78            for entry in WalkDir::new(&abs).follow_links(false) {
79                let entry = entry.with_context(|| format!("walking {}", abs.display()))?;
80                if !entry.file_type().is_file() {
81                    continue;
82                }
83                if entry.path().extension().and_then(|e| e.to_str()) != Some("gd") {
84                    continue;
85                }
86                let ep = entry
87                    .path()
88                    .canonicalize()
89                    .unwrap_or_else(|_| entry.path().to_path_buf());
90                // Match against project-root-relative path with forward slashes.
91                let rel = ep.strip_prefix(&project_abs).unwrap_or(&ep);
92                let rel_str = rel.to_string_lossy().replace('\\', "/");
93                if !patterns.iter().any(|p| glob_match(p, &rel_str)) {
94                    continue;
95                }
96                if exclude_patterns.iter().any(|p| glob_match(p, &rel_str)) {
97                    continue;
98                }
99                out.push(to_res_path(&ep, &project_abs)?);
100            }
101        } else {
102            return Err(anyhow!("path not found: {}", abs.display()));
103        }
104    }
105    out.sort();
106    out.dedup();
107    Ok(out)
108}
109
110/// Match a relative path (forward-slash separated) against a simple glob pattern.
111/// Supports `**` (zero or more path segments) and `*` (any chars within one segment).
112pub fn glob_match(pattern: &str, path: &str) -> bool {
113    let pat: Vec<&str> = pattern.split('/').collect();
114    let seg: Vec<&str> = path.split('/').collect();
115    glob_seg(&pat, &seg)
116}
117
118fn glob_seg(pat: &[&str], seg: &[&str]) -> bool {
119    if pat.is_empty() {
120        return seg.is_empty();
121    }
122    if pat[0] == "**" {
123        for i in 0..=seg.len() {
124            if glob_seg(&pat[1..], &seg[i..]) {
125                return true;
126            }
127        }
128        return false;
129    }
130    if seg.is_empty() {
131        return false;
132    }
133    if seg_matches(pat[0], seg[0]) {
134        return glob_seg(&pat[1..], &seg[1..]);
135    }
136    false
137}
138
139/// Match a single path segment against a pattern that may contain `*` wildcards.
140fn seg_matches(pattern: &str, s: &str) -> bool {
141    if !pattern.contains('*') {
142        return pattern == s;
143    }
144    let parts: Vec<&str> = pattern.split('*').collect();
145    let mut pos = 0usize;
146    for (i, part) in parts.iter().enumerate() {
147        if i == 0 {
148            if !s.starts_with(part) {
149                return false;
150            }
151            pos += part.len();
152        } else if i == parts.len() - 1 {
153            return s[pos..].ends_with(part);
154        } else {
155            match s[pos..].find(part) {
156                None => return false,
157                Some(idx) => pos += idx + part.len(),
158            }
159        }
160    }
161    true
162}
163
164/// If `path` contains a component named `describe_<Name>`, return that component name.
165/// Used to prefix test function names for directory-based sub-suites.
166pub fn extract_describe_dir(path: &Path) -> Option<String> {
167    path.components()
168        .filter_map(|c| match c {
169            std::path::Component::Normal(s) => s.to_str(),
170            _ => None,
171        })
172        .rfind(|s| s.starts_with("describe_"))
173        .map(|s| s.to_string())
174}
175
176/// Classification of a discovered test function.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum TestKind {
179    /// `test_*` — runs normally.
180    Normal,
181    /// `bench_*` — benchmark; only runs when `--bench` is passed.
182    Bench,
183}
184
185/// Per-test isolation override discovered from source comments.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum IsolationDirective {
188    File,
189    Test,
190}
191
192/// A discovered test function with its run classification.
193#[derive(Debug, Clone)]
194pub struct TestEntry {
195    pub name: String,
196    pub kind: TestKind,
197    /// Tags collected from `## @tag <name>` doc comments immediately preceding the function.
198    pub tags: Vec<String>,
199    /// Isolation override from source comments.
200    pub isolation: Option<IsolationDirective>,
201}
202
203/// Return every `func test_*()` and `func bench_*()` in the given GDScript file, in source order.
204///
205/// If the file lives inside a `describe_<Name>/` directory, all returned
206/// entry names are prefixed with `describe_<Name>/` to match the reporting
207/// format of inner-class describe blocks.
208pub fn list_test_entries_in(script_path: &Path) -> Result<Vec<TestEntry>> {
209    let source =
210        std::fs::read(script_path).with_context(|| format!("reading {}", script_path.display()))?;
211    let mut entries = list_test_entries_in_source(&source)?;
212    if let Some(prefix) = extract_describe_dir(script_path) {
213        for entry in &mut entries {
214            entry.name = format!("{prefix}/{}", entry.name);
215        }
216    }
217    Ok(entries)
218}
219
220pub fn list_test_entries_in_source(source: &[u8]) -> Result<Vec<TestEntry>> {
221    // Discovery works on lines, which invalid UTF-8 replaced by gdsyntax
222    // cannot move.
223    let tree = gdsyntax::parse_bytes(source);
224    let file_isolation = extract_file_isolation(source);
225    let mut out = Vec::new();
226    collect_test_entries(
227        &tree,
228        tree.file().members(),
229        source,
230        None,
231        file_isolation,
232        &mut out,
233    );
234    Ok(out)
235}
236
237/// Parse a `## @tag <name>` doc comment. Returns the tag name or `None`.
238fn parse_tag_comment(text: &str) -> Option<String> {
239    let body = text.trim().strip_prefix("##")?.trim_start();
240    let name = body.strip_prefix("@tag")?.trim();
241    if name.is_empty() || name.contains(char::is_whitespace) {
242        return None;
243    }
244    Some(name.to_string())
245}
246
247fn parse_isolation_comment(text: &str) -> Option<IsolationDirective> {
248    let trimmed = text.trim();
249    let doc_body = trimmed.strip_prefix("##").map(str::trim_start);
250    let plain_body = trimmed.strip_prefix('#').map(str::trim_start);
251
252    if let Some(body) = doc_body
253        && let Some(value) = body.strip_prefix("@isolation").map(str::trim)
254    {
255        return match value {
256            "file" => Some(IsolationDirective::File),
257            "test" => Some(IsolationDirective::Test),
258            _ => None,
259        };
260    }
261
262    if let Some(body) = plain_body
263        && let Some(value) = body
264            .strip_prefix("gdtest:")
265            .map(str::trim_start)
266            .and_then(|rest| rest.strip_prefix("isolation="))
267            .map(str::trim)
268    {
269        return match value {
270            "file" => Some(IsolationDirective::File),
271            "test" => Some(IsolationDirective::Test),
272            _ => None,
273        };
274    }
275
276    None
277}
278
279/// Scan source lines immediately before `func_row` and collect `## @tag` names.
280/// Stops at a blank line or any line that is neither a `## @tag` nor a plain `#` comment.
281fn extract_tags_preceding(source: &[u8], func_row: usize) -> Vec<String> {
282    let lines: Vec<&[u8]> = source.split(|&b| b == b'\n').collect();
283    let mut tags = Vec::new();
284    let mut row = func_row;
285    while row > 0 {
286        row -= 1;
287        let line = std::str::from_utf8(lines[row]).unwrap_or("").trim();
288        if line.is_empty() {
289            break;
290        }
291        if let Some(tag) = parse_tag_comment(line) {
292            tags.push(tag);
293        } else if line.starts_with('#') {
294            // plain comment — keep scanning upward
295        } else {
296            break;
297        }
298    }
299    tags.reverse();
300    tags
301}
302
303fn extract_isolation_preceding(source: &[u8], func_row: usize) -> Option<IsolationDirective> {
304    let lines: Vec<&[u8]> = source.split(|&b| b == b'\n').collect();
305    let mut row = func_row;
306    while row > 0 {
307        row -= 1;
308        let line = std::str::from_utf8(lines[row]).unwrap_or("").trim();
309        if line.is_empty() {
310            break;
311        }
312        if let Some(isolation) = parse_isolation_comment(line) {
313            return Some(isolation);
314        }
315        if line.starts_with('#') {
316            continue;
317        }
318        break;
319    }
320    None
321}
322
323fn extract_file_isolation(source: &[u8]) -> Option<IsolationDirective> {
324    for raw_line in source.split(|&b| b == b'\n') {
325        let line = std::str::from_utf8(raw_line).unwrap_or("").trim();
326        if line.is_empty() {
327            continue;
328        }
329        if let Some(isolation) = parse_isolation_comment(line) {
330            return Some(isolation);
331        }
332        if line.starts_with('#') {
333            continue;
334        }
335        break;
336    }
337    None
338}
339
340/// Classify a function name into its TestKind, or `None` if it's not a test.
341fn classify_func(name: &str) -> Option<TestKind> {
342    if name.starts_with("bench_") {
343        Some(TestKind::Bench)
344    } else if name.starts_with("test_") {
345        Some(TestKind::Normal)
346    } else {
347        None
348    }
349}
350
351/// Collect test entries from the members of a file or of a `describe_*` class.
352/// `describe` is the name of the enclosing `describe_*` inner class, if any.
353fn collect_test_entries(
354    tree: &gdsyntax::SyntaxTree,
355    members: impl Iterator<Item = Member>,
356    source: &[u8],
357    describe: Option<&str>,
358    file_isolation: Option<IsolationDirective>,
359    out: &mut Vec<TestEntry>,
360) {
361    for member in members {
362        match member {
363            Member::Func(func) => {
364                let Some(name_token) = func.name() else {
365                    continue;
366                };
367                let fname = name_token.text();
368                let Some(kind) = classify_func(fname) else {
369                    continue;
370                };
371                let name = match describe {
372                    Some(cls) => format!("{cls}/{fname}"),
373                    None => fname.to_string(),
374                };
375                // The node starts at the function's first annotation, so tags
376                // and directives are read from the lines above it.
377                let row = tree
378                    .position(func.syntax().text_range().start().into())
379                    .line
380                    - 1;
381                let tags = extract_tags_preceding(source, row);
382                let isolation = extract_isolation_preceding(source, row).or(file_isolation);
383                out.push(TestEntry {
384                    name,
385                    kind,
386                    tags,
387                    isolation,
388                });
389            }
390            // Only top-level `describe_*` inner classes hold tests; the worker
391            // instantiates them by name, so classes nested deeper are skipped.
392            Member::Class(class) if describe.is_none() => {
393                if let Some(name_token) = class.name()
394                    && name_token.text().starts_with("describe_")
395                {
396                    let members: Vec<Member> = class
397                        .body()
398                        .map(|body| body.members().collect())
399                        .unwrap_or_default();
400                    collect_test_entries(
401                        tree,
402                        members.into_iter(),
403                        source,
404                        Some(name_token.text()),
405                        file_isolation,
406                        out,
407                    );
408                }
409            }
410            _ => {}
411        }
412    }
413}
414
415/// Convenience wrapper: returns the names of tests that would actually run
416/// (excludes `bench_*`).
417pub fn list_tests_in(script_path: &Path) -> Result<Vec<String>> {
418    let entries = list_test_entries_in(script_path)?;
419    Ok(entries
420        .into_iter()
421        .filter(|e| e.kind == TestKind::Normal)
422        .map(|e| e.name)
423        .collect())
424}
425
426/// Translate an absolute on-disk path to the res:// form used by the
427/// worker. Caller guarantees `file` is inside `project_abs`.
428pub fn to_res_path(file: &Path, project_abs: &Path) -> Result<String> {
429    let rel = file.strip_prefix(project_abs).with_context(|| {
430        format!(
431            "{} is outside project {}",
432            file.display(),
433            project_abs.display()
434        )
435    })?;
436    let s = rel.to_string_lossy().replace('\\', "/");
437    Ok(format!("res://{s}"))
438}
439
440/// Translate a res:// path to its on-disk absolute form.
441pub fn res_to_abs(res_path: &str, project_abs: &Path) -> Result<PathBuf> {
442    let rel = res_path
443        .strip_prefix("res://")
444        .ok_or_else(|| anyhow!("expected res:// prefix: {res_path}"))?;
445    Ok(project_abs.join(rel))
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use std::fs;
452    use std::path::PathBuf;
453    use std::time::{SystemTime, UNIX_EPOCH};
454
455    fn make_temp_project(prefix: &str) -> PathBuf {
456        let nonce = SystemTime::now()
457            .duration_since(UNIX_EPOCH)
458            .unwrap()
459            .as_nanos();
460        let dir = std::env::temp_dir().join(format!("gdtest-{prefix}-{nonce}"));
461        fs::create_dir_all(&dir).unwrap();
462        dir
463    }
464
465    #[test]
466    fn extracts_test_functions() {
467        let src = br#"
468extends RefCounted
469
470func _helper() -> void:
471    pass
472
473func test_alpha() -> void:
474    pass
475
476func test_beta(arg: int) -> void:
477    # func test_inside_comment() should NOT be picked up
478    pass
479
480func not_a_test() -> void:
481    pass
482
483# Async test
484func test_gamma() -> void:
485    await physics_frame
486"#;
487        let entries = list_test_entries_in_source(src).unwrap();
488        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
489        assert_eq!(names, vec!["test_alpha", "test_beta", "test_gamma"]);
490        assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
491    }
492
493    #[test]
494    fn unknown_prefixes_are_not_tests() {
495        let src = br#"
496func test_normal() -> void:
497    pass
498func xtest_skipped() -> void:
499    pass
500func ftest_focused() -> void:
501    pass
502func helper() -> void:
503    pass
504"#;
505        let entries = list_test_entries_in_source(src).unwrap();
506        // xtest_* and ftest_* are not recognized — only test_* is.
507        assert_eq!(entries.len(), 1);
508        assert_eq!(entries[0].name, "test_normal");
509        assert_eq!(entries[0].kind, TestKind::Normal);
510    }
511
512    #[test]
513    fn parses_tag_comments() {
514        let src = br#"
515extends GdTest
516
517## @tag slow
518## @tag integration
519func test_heavy() -> void:
520    pass
521
522## regular comment, not a tag
523func test_untagged() -> void:
524    pass
525
526func test_also_untagged() -> void:
527    pass
528"#;
529        let entries = list_test_entries_in_source(src).unwrap();
530        assert_eq!(entries.len(), 3);
531        assert_eq!(entries[0].name, "test_heavy");
532        assert_eq!(entries[0].tags, vec!["slow", "integration"]);
533        assert!(entries[1].tags.is_empty());
534        assert!(entries[2].tags.is_empty());
535    }
536
537    #[test]
538    fn parses_file_header_isolation_directive() {
539        let src = br#"
540# gdtest: isolation=test
541extends GdTest
542
543func test_one() -> void:
544    pass
545
546func test_two() -> void:
547    pass
548"#;
549        let entries = list_test_entries_in_source(src).unwrap();
550        assert_eq!(entries.len(), 2);
551        assert_eq!(entries[0].isolation, Some(IsolationDirective::Test));
552        assert_eq!(entries[1].isolation, Some(IsolationDirective::Test));
553    }
554
555    #[test]
556    fn function_isolation_overrides_file_header() {
557        let src = br#"
558## @isolation test
559extends GdTest
560
561## @isolation file
562func test_one() -> void:
563    pass
564
565func test_two() -> void:
566    pass
567"#;
568        let entries = list_test_entries_in_source(src).unwrap();
569        assert_eq!(entries.len(), 2);
570        assert_eq!(entries[0].isolation, Some(IsolationDirective::File));
571        assert_eq!(entries[1].isolation, Some(IsolationDirective::Test));
572    }
573
574    #[test]
575    fn classifies_bench_funcs() {
576        let src = br#"
577func test_normal() -> void:
578    pass
579func bench_pathfinding() -> void:
580    pass
581"#;
582        let entries = list_test_entries_in_source(src).unwrap();
583        assert_eq!(entries.len(), 2);
584        assert_eq!(entries[0].kind, TestKind::Normal);
585        assert_eq!(entries[1].kind, TestKind::Bench);
586        // bench_* excluded from list_tests_in
587        let runnable: Vec<String> = entries
588            .into_iter()
589            .filter(|e| e.kind == TestKind::Normal)
590            .map(|e| e.name)
591            .collect();
592        assert_eq!(runnable, vec!["test_normal"]);
593    }
594
595    #[test]
596    fn empty_on_parse_error_tolerates_partial() {
597        let src = br#"func test_recoverable(): pass
598!@#$%garbage that breaks parsing"#;
599        let _entries = list_test_entries_in_source(src).unwrap();
600    }
601
602    #[test]
603    fn discovers_describe_class_tests() {
604        let src = br#"
605extends GdTest
606
607func test_top_level() -> void:
608    pass
609
610class describe_Movement:
611    func test_moves_right() -> void:
612        pass
613    func test_moves_left() -> void:
614        pass
615    func helper_not_a_test() -> void:
616        pass
617
618class IgnoredClass:
619    func test_inside_ignored() -> void:
620        pass
621"#;
622        let entries = list_test_entries_in_source(src).unwrap();
623        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
624        assert_eq!(
625            names,
626            vec![
627                "test_top_level",
628                "describe_Movement/test_moves_right",
629                "describe_Movement/test_moves_left",
630            ]
631        );
632        assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
633    }
634
635    #[test]
636    fn discovers_non_ascii_test_names() {
637        let src = "func test_日本語() -> void:\n\tpass\n".as_bytes();
638        let entries = list_test_entries_in_source(src).unwrap();
639        assert_eq!(entries[0].name, "test_日本語");
640    }
641
642    #[test]
643    fn reads_tags_above_annotations() {
644        let src = br#"
645## @tag slow
646@warning_ignore("unused_variable")
647func test_annotated() -> void:
648    var x := 1
649"#;
650        let entries = list_test_entries_in_source(src).unwrap();
651        assert_eq!(entries[0].tags, vec!["slow"]);
652    }
653
654    #[test]
655    fn finds_tests_after_a_syntax_error() {
656        let src = b"func broken(:\n\tpass\n\nfunc test_after() -> void:\n\tpass\n";
657        let entries = list_test_entries_in_source(src).unwrap();
658        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
659        assert_eq!(names, vec!["test_after"]);
660    }
661
662    #[test]
663    fn skips_classes_nested_in_describe_classes() {
664        let src = br#"
665class describe_Outer:
666    func test_outer() -> void:
667        pass
668    class describe_Inner:
669        func test_inner() -> void:
670            pass
671"#;
672        let entries = list_test_entries_in_source(src).unwrap();
673        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
674        assert_eq!(names, vec!["describe_Outer/test_outer"]);
675    }
676
677    // ── glob_match tests ──────────────────────────────────────────────────────
678
679    #[test]
680    fn glob_match_test_dir_pattern() {
681        assert!(glob_match("test/**/*.gd", "test/battle.gd"));
682        assert!(glob_match("test/**/*.gd", "test/docs/test_foo.gd"));
683        assert!(glob_match("test/**/*.gd", "test/docs/dungeon/battle.gd"));
684        assert!(!glob_match("test/**/*.gd", "src/battle.gd"));
685        assert!(!glob_match("test/**/*.gd", "test/battle.tscn"));
686    }
687
688    #[test]
689    fn glob_match_colocated_pattern() {
690        assert!(glob_match("**/*.test.gd", "battle.test.gd"));
691        assert!(glob_match("**/*.test.gd", "src/battle/battle.test.gd"));
692        assert!(glob_match("**/*.test.gd", "deep/nested/dir/foo.test.gd"));
693        assert!(!glob_match("**/*.test.gd", "test_battle.gd"));
694        assert!(!glob_match("**/*.test.gd", "battle.gd"));
695    }
696
697    #[test]
698    fn glob_match_star_prefix_legacy() {
699        // Backward-compat: users may set patterns = ["test/test_*.gd"]
700        assert!(glob_match("test/test_*.gd", "test/test_battle.gd"));
701        assert!(!glob_match("test/test_*.gd", "test/battle.gd"));
702    }
703
704    // ── extract_describe_dir tests ────────────────────────────────────────────
705
706    #[test]
707    fn extract_describe_dir_finds_prefix() {
708        use std::path::PathBuf;
709        let p = PathBuf::from("test/describe_Battle/basic_hit.gd");
710        assert_eq!(
711            extract_describe_dir(&p),
712            Some("describe_Battle".to_string())
713        );
714    }
715
716    #[test]
717    fn extract_describe_dir_none_for_normal() {
718        use std::path::PathBuf;
719        let p = PathBuf::from("test/docs/test_foo.gd");
720        assert_eq!(extract_describe_dir(&p), None);
721    }
722
723    #[test]
724    fn extract_describe_dir_returns_last() {
725        use std::path::PathBuf;
726        // Nested describe dirs: return innermost
727        let p = PathBuf::from("test/describe_Outer/describe_Inner/foo.gd");
728        assert_eq!(extract_describe_dir(&p), Some("describe_Inner".to_string()));
729    }
730
731    #[test]
732    fn resolve_excludes_discovery_globs_for_directory_walks() {
733        let project = make_temp_project("resolve-exclude-dir");
734        fs::create_dir_all(project.join("test/helpers")).unwrap();
735        fs::create_dir_all(project.join("test/cases")).unwrap();
736        fs::write(
737            project.join("test/helpers/helper.gd"),
738            "extends RefCounted\n",
739        )
740        .unwrap();
741        fs::write(
742            project.join("test/cases/test_ok.gd"),
743            "extends RefCounted\n",
744        )
745        .unwrap();
746
747        let inputs = vec!["test".to_string()];
748        let patterns = vec!["test/**/*.gd".to_string()];
749        let excludes = vec!["test/helpers/**".to_string()];
750        let resolved = resolve(&inputs, &project, &patterns, &excludes).unwrap();
751
752        assert_eq!(resolved, vec!["res://test/cases/test_ok.gd"]);
753
754        fs::remove_dir_all(project).ok();
755    }
756
757    #[test]
758    fn resolve_keeps_explicit_file_targets_even_if_excluded() {
759        let project = make_temp_project("resolve-explicit-file");
760        fs::create_dir_all(project.join("test/helpers")).unwrap();
761        let helper = project.join("test/helpers/helper.gd");
762        fs::write(&helper, "extends RefCounted\n").unwrap();
763
764        let inputs = vec![helper.to_string_lossy().into_owned()];
765        let patterns = vec!["test/**/*.gd".to_string()];
766        let excludes = vec!["test/helpers/**".to_string()];
767        let resolved = resolve(&inputs, &project, &patterns, &excludes).unwrap();
768
769        assert_eq!(resolved, vec!["res://test/helpers/helper.gd"]);
770
771        fs::remove_dir_all(project).ok();
772    }
773}