Skip to main content

batuta/falsification/
helpers.rs

1//! Shared helper functions for falsification checks.
2//!
3//! Eliminates duplicated pattern-matching logic across falsification modules.
4
5use std::path::Path;
6
7use super::types::CheckItem;
8
9/// Represents the outcome to apply to a [`CheckItem`].
10///
11/// Used with [`apply_check_outcome`] to replace repeated if-else outcome chains
12/// in falsification check functions.
13pub(crate) enum CheckOutcome<'a> {
14    /// Pass the check (no message).
15    Pass,
16    /// Partial pass with a diagnostic message.
17    Partial(&'a str),
18    /// Fail with a diagnostic message.
19    Fail(&'a str),
20}
21
22/// Apply the first matching outcome to a check item.
23///
24/// Iterates through `checks` and applies the outcome for the first entry
25/// whose condition is `true`. If no condition matches, returns the item unchanged.
26pub(crate) fn apply_check_outcome(
27    item: CheckItem,
28    checks: &[(bool, CheckOutcome<'_>)],
29) -> CheckItem {
30    for (condition, outcome) in checks {
31        if *condition {
32            return match outcome {
33                CheckOutcome::Pass => item.pass(),
34                CheckOutcome::Partial(msg) => item.partial(*msg),
35                CheckOutcome::Fail(msg) => item.fail(*msg),
36            };
37        }
38    }
39    item
40}
41
42/// Check if any file matching the glob patterns contains any of the search patterns.
43///
44/// Used by all falsification modules for source/config file scanning.
45pub(crate) fn files_contain_pattern(
46    project_path: &Path,
47    glob_patterns: &[&str],
48    search_patterns: &[&str],
49) -> bool {
50    for glob_pat in glob_patterns {
51        let full = format!("{}/{}", project_path.display(), glob_pat);
52        let Ok(entries) = glob::glob(&full) else {
53            continue;
54        };
55        for entry in entries.flatten() {
56            let Ok(content) = std::fs::read_to_string(&entry) else {
57                continue;
58            };
59            if search_patterns.iter().any(|p| content.contains(p)) {
60                return true;
61            }
62        }
63    }
64    false
65}
66
67/// Check if any file matching the glob patterns contains any search pattern (case-insensitive).
68pub(crate) fn files_contain_pattern_ci(
69    project_path: &Path,
70    glob_patterns: &[&str],
71    search_patterns: &[&str],
72) -> bool {
73    for glob_pat in glob_patterns {
74        let full = format!("{}/{}", project_path.display(), glob_pat);
75        let Ok(entries) = glob::glob(&full) else {
76            continue;
77        };
78        for entry in entries.flatten() {
79            let Ok(content) = std::fs::read_to_string(&entry) else {
80                continue;
81            };
82            let lower = content.to_lowercase();
83            if search_patterns.iter().any(|p| lower.contains(&p.to_lowercase())) {
84                return true;
85            }
86        }
87    }
88    false
89}
90
91/// Check if any Rust source file contains any of the given patterns.
92pub(crate) fn source_contains_pattern(project_path: &Path, patterns: &[&str]) -> bool {
93    files_contain_pattern(project_path, &["src/**/*.rs"], patterns)
94}
95
96/// Check if any Rust source or config file contains any of the given patterns.
97pub(crate) fn source_or_config_contains_pattern(project_path: &Path, patterns: &[&str]) -> bool {
98    files_contain_pattern(
99        project_path,
100        &["src/**/*.rs", "**/*.yaml", "**/*.toml", "**/*.json"],
101        patterns,
102    )
103}
104
105/// Check if any CI workflow file contains any of the given patterns (case-insensitive).
106pub(crate) fn ci_contains_pattern(project_path: &Path, patterns: &[&str]) -> bool {
107    files_contain_pattern_ci(
108        project_path,
109        &[".github/workflows/*.yml", ".github/workflows/*.yaml", ".gitlab-ci.yml"],
110        patterns,
111    )
112}
113
114/// Check if any test file contains any of the given patterns.
115///
116/// Searches test directories, test-named files, and `#[cfg(test)]` modules.
117pub(crate) fn test_contains_pattern(project_path: &Path, patterns: &[&str]) -> bool {
118    if files_contain_pattern(project_path, &["tests/**/*.rs", "src/**/*test*.rs"], patterns) {
119        return true;
120    }
121
122    // Also check #[cfg(test)] modules in source files
123    let glob_str = format!("{}/src/**/*.rs", project_path.display());
124    let Ok(entries) = glob::glob(&glob_str) else {
125        return false;
126    };
127    for entry in entries.flatten() {
128        let Ok(content) = std::fs::read_to_string(&entry) else {
129            continue;
130        };
131        if content.contains("#[cfg(test)]") && patterns.iter().any(|p| content.contains(p)) {
132            return true;
133        }
134    }
135    false
136}
137
138/// Count how many platforms from the list appear in CI workflow files.
139pub(crate) fn ci_platform_count(project_path: &Path, platforms: &[&str]) -> usize {
140    let ci_globs = [
141        format!("{}/.github/workflows/*.yml", project_path.display()),
142        format!("{}/.github/workflows/*.yaml", project_path.display()),
143    ];
144
145    for glob_pattern in &ci_globs {
146        let Ok(entries) = glob::glob(glob_pattern) else {
147            continue;
148        };
149        for entry in entries.flatten() {
150            let Ok(content) = std::fs::read_to_string(&entry) else {
151                continue;
152            };
153            let count = platforms.iter().filter(|p| content.contains(*p)).count();
154            if count >= 2 {
155                return count;
156            }
157        }
158    }
159    0
160}
161
162/// Scan Cargo.toml for scripting runtime dependencies.
163///
164/// Returns list of forbidden dependency names found in non-dev deps.
165pub(crate) fn find_scripting_deps(project_path: &Path) -> Vec<String> {
166    let cargo_toml = project_path.join("Cargo.toml");
167    let Ok(content) = std::fs::read_to_string(&cargo_toml) else {
168        return Vec::new();
169    };
170
171    let forbidden = ["pyo3", "napi", "mlua", "rlua", "rustpython"];
172    let mut found = Vec::new();
173
174    for dep in forbidden {
175        let has_dep = content.contains(&format!("{dep} =")) || content.contains(&format!("{dep}="));
176        if !has_dep {
177            continue;
178        }
179        // Rough check: in [dependencies] but not after [dev-dependencies]
180        let is_dev_only = content.contains("[dev-dependencies]")
181            && content.find(&format!("{dep} =")) >= content.find("[dev-dependencies]");
182        if !is_dev_only {
183            found.push(dep.to_string());
184        }
185    }
186    found
187}
188
189/// Detect serde/schema validation configuration from Cargo.toml.
190pub(crate) struct SchemaInfo {
191    pub has_serde: bool,
192    pub has_serde_yaml: bool,
193    pub has_validator: bool,
194}
195
196/// Read schema-related dependency info from project files.
197///
198/// Checks Cargo.toml for Rust projects. For non-Rust projects, detects
199/// alternative schema validation signals: pv/forjar validation in Makefiles
200/// or CI configs, Python validation libraries (pydantic, marshmallow), and
201/// JSON Schema files.
202pub(crate) fn detect_schema_deps(project_path: &Path) -> SchemaInfo {
203    let cargo_toml = project_path.join("Cargo.toml");
204    let content = std::fs::read_to_string(&cargo_toml).unwrap_or_default();
205
206    if !content.is_empty() {
207        return detect_rust_schema_deps(&content);
208    }
209
210    detect_nonrust_schema_deps(project_path)
211}
212
213fn detect_rust_schema_deps(cargo_content: &str) -> SchemaInfo {
214    SchemaInfo {
215        has_serde: cargo_content.contains("serde"),
216        has_serde_yaml: cargo_content.contains("serde_yaml")
217            || cargo_content.contains("serde_yml")
218            || cargo_content.contains("serde_yaml_ng"),
219        has_validator: cargo_content.contains("validator") || cargo_content.contains("garde"),
220    }
221}
222
223fn detect_nonrust_schema_deps(project_path: &Path) -> SchemaInfo {
224    let mut has_schema_tool = false;
225    let mut has_yaml_support = false;
226    let mut has_validator = false;
227
228    if has_validation_in_build_files(project_path) {
229        has_schema_tool = true;
230        has_yaml_support = true;
231        has_validator = true;
232    }
233
234    let py_info = detect_python_schema_deps(project_path);
235    has_schema_tool = has_schema_tool || py_info.has_serde;
236    has_yaml_support = has_yaml_support || py_info.has_serde_yaml;
237    has_validator = has_validator || py_info.has_validator;
238
239    SchemaInfo { has_serde: has_schema_tool, has_serde_yaml: has_yaml_support, has_validator }
240}
241
242const VALIDATION_COMMANDS: &[&str] =
243    &["pv validate", "forjar validate", "batuta playbook validate"];
244
245fn has_validation_in_build_files(project_path: &Path) -> bool {
246    let makefile_content =
247        std::fs::read_to_string(project_path.join("Makefile")).unwrap_or_default();
248    if VALIDATION_COMMANDS.iter().any(|cmd| makefile_content.contains(cmd)) {
249        return true;
250    }
251    has_validation_in_ci(project_path)
252}
253
254fn has_validation_in_ci(project_path: &Path) -> bool {
255    let pattern = format!("{}/.github/workflows/*.y*ml", project_path.display());
256    let Ok(entries) = glob::glob(&pattern) else {
257        return false;
258    };
259    for entry in entries.flatten() {
260        let content = std::fs::read_to_string(&entry).unwrap_or_default();
261        if VALIDATION_COMMANDS.iter().any(|cmd| content.contains(cmd)) {
262            return true;
263        }
264    }
265    false
266}
267
268fn detect_python_schema_deps(project_path: &Path) -> SchemaInfo {
269    let mut info = SchemaInfo { has_serde: false, has_serde_yaml: false, has_validator: false };
270    for pyfile in ["pyproject.toml", "setup.cfg", "requirements.txt"] {
271        let content = std::fs::read_to_string(project_path.join(pyfile)).unwrap_or_default();
272        if content.contains("pydantic")
273            || content.contains("marshmallow")
274            || content.contains("cerberus")
275            || content.contains("jsonschema")
276        {
277            info.has_serde = true;
278            info.has_validator = true;
279        }
280        if content.contains("pyyaml") || content.contains("ruamel") {
281            info.has_serde_yaml = true;
282        }
283    }
284    info
285}
286
287/// Check if any source file has a config struct with Deserialize derive,
288/// or if the project uses external schema validation tools (pv, forjar).
289pub(crate) fn has_deserialize_config_struct(project_path: &Path) -> bool {
290    has_rust_config_struct(project_path)
291        || has_pv_contracts(project_path)
292        || has_json_schema_files(project_path)
293}
294
295fn has_rust_config_struct(project_path: &Path) -> bool {
296    let glob_str = format!("{}/src/**/*.rs", project_path.display());
297    let entries = match glob::glob(&glob_str) {
298        Ok(e) => e,
299        Err(_) => return false,
300    };
301    for entry in entries.flatten() {
302        let content = match std::fs::read_to_string(&entry) {
303            Ok(c) => c,
304            Err(_) => continue,
305        };
306        let is_config = content.contains("#[derive")
307            && content.contains("Deserialize")
308            && content.contains("struct")
309            && content.to_lowercase().contains("config");
310        if is_config {
311            return true;
312        }
313    }
314    false
315}
316
317fn has_pv_contracts(project_path: &Path) -> bool {
318    for dir in ["contracts", "contract"] {
319        let pattern = format!("{}/{}/**/*.yaml", project_path.display(), dir);
320        let Ok(entries) = glob::glob(&pattern) else {
321            continue;
322        };
323        for entry in entries.flatten() {
324            let content = std::fs::read_to_string(&entry).unwrap_or_default();
325            if content.contains("proof_obligations:") || content.contains("metadata:") {
326                return true;
327            }
328        }
329    }
330    false
331}
332
333fn has_json_schema_files(project_path: &Path) -> bool {
334    let pattern = format!("{}/**/*.schema.json", project_path.display());
335    glob::glob(&pattern).ok().and_then(|mut e| e.next()).is_some()
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use std::path::PathBuf;
342
343    #[test]
344    fn test_source_contains_pattern_finds_struct() {
345        let path = PathBuf::from(".");
346        assert!(source_contains_pattern(&path, &["struct"]));
347    }
348
349    #[test]
350    fn test_source_contains_pattern_nonexistent_path() {
351        let path = PathBuf::from("/nonexistent/path");
352        assert!(!source_contains_pattern(&path, &["anything"]));
353    }
354
355    #[test]
356    fn test_files_contain_pattern_ci_nonexistent_path() {
357        let path = PathBuf::from("/nonexistent/path");
358        assert!(!files_contain_pattern_ci(&path, &["src/**/*.rs"], &["anything"]));
359    }
360
361    #[test]
362    fn test_find_scripting_deps_current_project() {
363        let path = PathBuf::from(".");
364        let deps = find_scripting_deps(&path);
365        // batuta should not have scripting deps
366        assert!(deps.is_empty());
367    }
368
369    #[test]
370    fn test_detect_schema_deps_current_project() {
371        let path = PathBuf::from(".");
372        let info = detect_schema_deps(&path);
373        // batuta uses serde
374        assert!(info.has_serde);
375    }
376
377    #[test]
378    fn test_has_deserialize_config_struct_nonexistent() {
379        let path = PathBuf::from("/nonexistent/path");
380        assert!(!has_deserialize_config_struct(&path));
381    }
382
383    #[test]
384    fn test_test_contains_pattern_finds_test() {
385        let path = PathBuf::from(".");
386        assert!(test_contains_pattern(&path, &["#[test]"]));
387    }
388
389    #[test]
390    fn test_ci_platform_count_current_project() {
391        let path = PathBuf::from(".");
392        // May or may not have CI - just verify no panic
393        let _ = ci_platform_count(&path, &["ubuntu", "macos", "windows"]);
394    }
395
396    // =========================================================================
397    // Coverage gap: invalid glob patterns
398    // =========================================================================
399
400    #[test]
401    fn test_files_contain_pattern_invalid_glob() {
402        let path = PathBuf::from(".");
403        // Invalid glob pattern with unclosed bracket - should not panic, returns false
404        assert!(!files_contain_pattern(&path, &["src/[invalid"], &["anything"]));
405    }
406
407    #[test]
408    fn test_files_contain_pattern_ci_invalid_glob() {
409        let path = PathBuf::from(".");
410        assert!(!files_contain_pattern_ci(&path, &["src/[invalid"], &["anything"]));
411    }
412
413    // =========================================================================
414    // Coverage gap: ci_platform_count with nonexistent path
415    // =========================================================================
416
417    #[test]
418    fn test_ci_platform_count_nonexistent_path() {
419        let path = PathBuf::from("/nonexistent/path");
420        assert_eq!(ci_platform_count(&path, &["ubuntu", "macos"]), 0);
421    }
422
423    #[test]
424    fn test_ci_platform_count_invalid_glob_pattern() {
425        let path = PathBuf::from(".");
426        // ci_platform_count uses hardcoded globs for .github/workflows/*.yml
427        // With a normal path but no CI files, should return 0
428        let count = ci_platform_count(&path, &["ubuntu", "macos", "windows"]);
429        assert!(count <= 3); // At most 3 platforms
430    }
431
432    // =========================================================================
433    // Coverage gap: apply_check_outcome variants
434    // =========================================================================
435
436    #[test]
437    fn test_apply_check_outcome_pass() {
438        let item = CheckItem::new("T-01", "Test", "Claim");
439        let result = apply_check_outcome(item, &[(true, CheckOutcome::Pass)]);
440        assert_eq!(result.status, super::super::types::CheckStatus::Pass);
441    }
442
443    #[test]
444    fn test_apply_check_outcome_partial() {
445        let item = CheckItem::new("T-01", "Test", "Claim");
446        let result = apply_check_outcome(item, &[(true, CheckOutcome::Partial("partial reason"))]);
447        assert_eq!(result.status, super::super::types::CheckStatus::Partial);
448        assert_eq!(result.rejection_reason, Some("partial reason".to_string()));
449    }
450
451    #[test]
452    fn test_apply_check_outcome_fail() {
453        let item = CheckItem::new("T-01", "Test", "Claim");
454        let result = apply_check_outcome(item, &[(true, CheckOutcome::Fail("fail reason"))]);
455        assert_eq!(result.status, super::super::types::CheckStatus::Fail);
456        assert_eq!(result.rejection_reason, Some("fail reason".to_string()));
457    }
458
459    #[test]
460    fn test_apply_check_outcome_no_match() {
461        let item = CheckItem::new("T-01", "Test", "Claim");
462        let result = apply_check_outcome(
463            item,
464            &[(false, CheckOutcome::Pass), (false, CheckOutcome::Fail("nope"))],
465        );
466        // No condition matched, item returned unchanged (Skipped default)
467        assert_eq!(result.status, super::super::types::CheckStatus::Skipped);
468    }
469
470    #[test]
471    fn test_apply_check_outcome_first_match_wins() {
472        let item = CheckItem::new("T-01", "Test", "Claim");
473        let result = apply_check_outcome(
474            item,
475            &[
476                (false, CheckOutcome::Fail("should not match")),
477                (true, CheckOutcome::Partial("second wins")),
478                (true, CheckOutcome::Pass), // should not be reached
479            ],
480        );
481        assert_eq!(result.status, super::super::types::CheckStatus::Partial);
482        assert_eq!(result.rejection_reason, Some("second wins".to_string()));
483    }
484
485    // =========================================================================
486    // Coverage gap: find_scripting_deps edge cases
487    // =========================================================================
488
489    #[test]
490    fn test_find_scripting_deps_nonexistent_path() {
491        let path = PathBuf::from("/nonexistent/path");
492        let deps = find_scripting_deps(&path);
493        assert!(deps.is_empty());
494    }
495
496    // =========================================================================
497    // Coverage gap: detect_schema_deps nonexistent path
498    // =========================================================================
499
500    #[test]
501    fn test_detect_schema_deps_nonexistent_path() {
502        let path = PathBuf::from("/nonexistent/path");
503        let info = detect_schema_deps(&path);
504        assert!(!info.has_serde);
505        assert!(!info.has_serde_yaml);
506        assert!(!info.has_validator);
507    }
508
509    // =========================================================================
510    // Coverage gap: source_or_config_contains_pattern
511    // =========================================================================
512
513    #[test]
514    fn test_source_or_config_contains_pattern_finds_toml() {
515        let path = PathBuf::from(".");
516        // Should find patterns in Cargo.toml
517        assert!(source_or_config_contains_pattern(&path, &["[package]"]));
518    }
519
520    #[test]
521    fn test_source_or_config_contains_pattern_nonexistent() {
522        let path = PathBuf::from("/nonexistent/path");
523        assert!(!source_or_config_contains_pattern(&path, &["anything"]));
524    }
525
526    // =========================================================================
527    // Coverage gap: ci_contains_pattern
528    // =========================================================================
529
530    #[test]
531    fn test_ci_contains_pattern_nonexistent_path() {
532        let path = PathBuf::from("/nonexistent/path");
533        assert!(!ci_contains_pattern(&path, &["ubuntu"]));
534    }
535
536    // =========================================================================
537    // Coverage gap: test_contains_pattern with nonexistent path
538    // =========================================================================
539
540    #[test]
541    fn test_test_contains_pattern_nonexistent_path() {
542        let path = PathBuf::from("/nonexistent/path");
543        assert!(!test_contains_pattern(&path, &["#[test]"]));
544    }
545
546    // =========================================================================
547    // Coverage gap: has_deserialize_config_struct current project
548    // =========================================================================
549
550    #[test]
551    fn test_has_deserialize_config_struct_current_project() {
552        let path = PathBuf::from(".");
553        // Just exercise the code path - current project may or may not have one
554        let _ = has_deserialize_config_struct(&path);
555    }
556
557    // =========================================================================
558    // Coverage gap: files_contain_pattern_ci actual match
559    // =========================================================================
560
561    #[test]
562    fn test_files_contain_pattern_ci_matches_rust_source() {
563        let path = PathBuf::from(".");
564        // Use case-insensitive match on Rust source files
565        assert!(files_contain_pattern_ci(
566            &path,
567            &["src/**/*.rs"],
568            &["FN "] // lowercase "fn " should match via case insensitive
569        ));
570    }
571
572    #[test]
573    fn test_files_contain_pattern_ci_no_match() {
574        // Use a nonexistent path to guarantee no files match the glob
575        let path = PathBuf::from("/nonexistent/empty/dir");
576        assert!(!files_contain_pattern_ci(&path, &["src/**/*.rs"], &["fn"]));
577    }
578
579    // =========================================================================
580    // Coverage gap: find_scripting_deps with forbidden dependency present
581    // =========================================================================
582
583    #[test]
584    fn test_find_scripting_deps_with_forbidden_dep() {
585        let temp = std::env::temp_dir().join("batuta_test_scripting_deps");
586        let _ = std::fs::create_dir_all(&temp);
587        // Write a Cargo.toml with pyo3 in [dependencies] (not dev-dependencies)
588        std::fs::write(
589            temp.join("Cargo.toml"),
590            "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[dependencies]\npyo3 = \"0.20\"\n",
591        )
592        .expect("unexpected failure");
593
594        let deps = find_scripting_deps(&temp);
595        assert!(deps.contains(&"pyo3".to_string()), "Should find pyo3 in dependencies: {:?}", deps);
596
597        let _ = std::fs::remove_dir_all(&temp);
598    }
599
600    #[test]
601    fn test_find_scripting_deps_dev_only_dep() {
602        let temp = std::env::temp_dir().join("batuta_test_scripting_devonly");
603        let _ = std::fs::create_dir_all(&temp);
604        // pyo3 only in dev-dependencies — should not be flagged
605        std::fs::write(
606            temp.join("Cargo.toml"),
607            "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[dev-dependencies]\npyo3 = \"0.20\"\n",
608        )
609        .expect("unexpected failure");
610
611        let deps = find_scripting_deps(&temp);
612        // Dev-only deps should be filtered out (line 192 branch)
613        assert!(deps.is_empty(), "Dev-only dep should not be flagged: {:?}", deps);
614
615        let _ = std::fs::remove_dir_all(&temp);
616    }
617
618    // =========================================================================
619    // Coverage gap: ci_platform_count returning count >= 2 (line 167)
620    // =========================================================================
621
622    #[test]
623    fn test_ci_platform_count_with_workflow_file() {
624        let temp = std::env::temp_dir().join("batuta_test_ci_platforms");
625        let _ = std::fs::remove_dir_all(&temp);
626        let _ = std::fs::create_dir_all(temp.join(".github/workflows"));
627        // Create a workflow file with multiple platform names
628        std::fs::write(
629            temp.join(".github/workflows/ci.yml"),
630            "name: CI\non:\n  push:\njobs:\n  test:\n    strategy:\n      matrix:\n        os: [ubuntu-latest, macos-latest, windows-latest]\n",
631        )
632        .expect("unexpected failure");
633
634        let count = ci_platform_count(&temp, &["ubuntu", "macos", "windows"]);
635        assert!(count >= 2, "Should find at least 2 platforms in workflow: {}", count);
636
637        let _ = std::fs::remove_dir_all(&temp);
638    }
639
640    // =========================================================================
641    // Coverage gap: test_contains_pattern via cfg(test) fallback (lines 138-144)
642    // =========================================================================
643
644    #[test]
645    fn test_test_contains_pattern_via_cfg_test_module() {
646        // Create a temp project where test patterns exist only in
647        // #[cfg(test)] modules inside src files (not in tests/ dir)
648        let temp = std::env::temp_dir().join("batuta_test_cfg_test_fallback");
649        let _ = std::fs::remove_dir_all(&temp);
650        let _ = std::fs::create_dir_all(temp.join("src"));
651        std::fs::write(
652            temp.join("src/lib.rs"),
653            "pub fn add(a: i32, b: i32) -> i32 { a + b }\n\n\
654             #[cfg(test)]\n\
655             mod tests {\n\
656                 use super::*;\n\
657                 #[test]\n\
658                 fn test_add_unique_marker() { assert_eq!(add(1, 2), 3); }\n\
659             }\n",
660        )
661        .expect("unexpected failure");
662
663        // Search for the unique marker that only exists in #[cfg(test)] module
664        assert!(test_contains_pattern(&temp, &["test_add_unique_marker"]));
665
666        let _ = std::fs::remove_dir_all(&temp);
667    }
668
669    #[test]
670    fn test_test_contains_pattern_no_cfg_test() {
671        // Project with source files but no #[cfg(test)] and no tests/ dir
672        let temp = std::env::temp_dir().join("batuta_test_no_cfg_test");
673        let _ = std::fs::remove_dir_all(&temp);
674        let _ = std::fs::create_dir_all(temp.join("src"));
675        std::fs::write(temp.join("src/lib.rs"), "pub fn add(a: i32, b: i32) -> i32 { a + b }\n")
676            .expect("unexpected failure");
677
678        // No test modules, no tests/ dir — should return false
679        assert!(!test_contains_pattern(&temp, &["nonexistent_test_fn"]));
680
681        let _ = std::fs::remove_dir_all(&temp);
682    }
683
684    // =========================================================================
685    // Coverage gap: has_deserialize_config_struct finding a match
686    // =========================================================================
687
688    #[test]
689    fn test_has_deserialize_config_struct_found() {
690        let temp = std::env::temp_dir().join("batuta_test_deser_config");
691        let _ = std::fs::remove_dir_all(&temp);
692        let _ = std::fs::create_dir_all(temp.join("src"));
693        std::fs::write(
694            temp.join("src/lib.rs"),
695            "#[derive(serde::Deserialize)]\npub struct AppConfig {\n    pub name: String,\n}\n",
696        )
697        .expect("unexpected failure");
698
699        assert!(has_deserialize_config_struct(&temp));
700
701        let _ = std::fs::remove_dir_all(&temp);
702    }
703
704    #[test]
705    fn test_has_deserialize_config_struct_no_config() {
706        let temp = std::env::temp_dir().join("batuta_test_deser_noconfig");
707        let _ = std::fs::remove_dir_all(&temp);
708        let _ = std::fs::create_dir_all(temp.join("src"));
709        std::fs::write(
710            temp.join("src/lib.rs"),
711            "#[derive(serde::Deserialize)]\npub struct UserData {\n    pub id: u64,\n}\n",
712        )
713        .expect("unexpected failure");
714
715        // Has Deserialize + struct but not "config" in name — should return false
716        assert!(!has_deserialize_config_struct(&temp));
717
718        let _ = std::fs::remove_dir_all(&temp);
719    }
720
721    #[test]
722    fn test_detect_schema_deps_serde_yaml_ng() {
723        let temp = std::env::temp_dir().join("batuta_test_schema_yaml_ng");
724        let _ = std::fs::remove_dir_all(&temp);
725        let _ = std::fs::create_dir_all(&temp);
726        std::fs::write(
727            temp.join("Cargo.toml"),
728            "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0\"\nserde_yaml_ng = \"0.10\"\n",
729        )
730        .expect("unexpected failure");
731
732        let info = detect_schema_deps(&temp);
733        assert!(info.has_serde);
734        assert!(info.has_serde_yaml, "serde_yaml_ng should be detected as has_serde_yaml");
735
736        let _ = std::fs::remove_dir_all(&temp);
737    }
738}