1use std::path::Path;
6
7use super::types::CheckItem;
8
9pub(crate) enum CheckOutcome<'a> {
14 Pass,
16 Partial(&'a str),
18 Fail(&'a str),
20}
21
22pub(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
42pub(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
67pub(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
91pub(crate) fn source_contains_pattern(project_path: &Path, patterns: &[&str]) -> bool {
93 files_contain_pattern(project_path, &["src/**/*.rs"], patterns)
94}
95
96pub(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
105pub(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
114pub(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 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
138pub(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
162pub(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 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
189pub(crate) struct SchemaInfo {
191 pub has_serde: bool,
192 pub has_serde_yaml: bool,
193 pub has_validator: bool,
194}
195
196pub(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
287pub(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 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 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 let _ = ci_platform_count(&path, &["ubuntu", "macos", "windows"]);
394 }
395
396 #[test]
401 fn test_files_contain_pattern_invalid_glob() {
402 let path = PathBuf::from(".");
403 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 #[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 let count = ci_platform_count(&path, &["ubuntu", "macos", "windows"]);
429 assert!(count <= 3); }
431
432 #[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 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), ],
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 #[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 #[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 #[test]
514 fn test_source_or_config_contains_pattern_finds_toml() {
515 let path = PathBuf::from(".");
516 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 #[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 #[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 #[test]
551 fn test_has_deserialize_config_struct_current_project() {
552 let path = PathBuf::from(".");
553 let _ = has_deserialize_config_struct(&path);
555 }
556
557 #[test]
562 fn test_files_contain_pattern_ci_matches_rust_source() {
563 let path = PathBuf::from(".");
564 assert!(files_contain_pattern_ci(
566 &path,
567 &["src/**/*.rs"],
568 &["FN "] ));
570 }
571
572 #[test]
573 fn test_files_contain_pattern_ci_no_match() {
574 let path = PathBuf::from("/nonexistent/empty/dir");
576 assert!(!files_contain_pattern_ci(&path, &["src/**/*.rs"], &["fn"]));
577 }
578
579 #[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 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 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 assert!(deps.is_empty(), "Dev-only dep should not be flagged: {:?}", deps);
614
615 let _ = std::fs::remove_dir_all(&temp);
616 }
617
618 #[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 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 #[test]
645 fn test_test_contains_pattern_via_cfg_test_module() {
646 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 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 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 assert!(!test_contains_pattern(&temp, &["nonexistent_test_fn"]));
680
681 let _ = std::fs::remove_dir_all(&temp);
682 }
683
684 #[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 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}