1mod build_config;
14mod build_variant;
15mod cargo;
16mod compile_commands;
17mod generated;
18mod language;
19mod source_unit;
20mod walk;
21
22pub use build_config::{
23 BuildConfiguration, CppBuild, EXCLUDED, EXCLUDED_WITH_VALUE, RustBuild, Setting, Shape,
24 content_hash,
25};
26pub use build_variant::{AnalysisMode, BuildVariant, NORMALIZATION_VERSION, Partition, partition};
27pub use cargo::{CargoLayout, PackageInfo};
28pub use compile_commands::{CompileCommands, CompileCommandsError, CompileEntry};
29pub use generated::{DEFAULT_MARKERS, DEFAULT_SCAN_LINES, GeneratedMarkers};
30pub use language::{Classification, HeaderEvidence, HeaderPolicy, Language, LanguageSelection};
31pub use source_unit::{ContentHash, SourceUnit, TargetKind};
32
33use std::path::{Path, PathBuf};
34use std::sync::Arc;
35
36use self::walk::WalkSettings;
37
38pub const DEFAULT_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
44
45const HEAD_BYTES: usize = 8 * 1024;
47
48#[derive(Debug, Clone)]
50pub struct DiscoveryConfig {
51 pub respect_gitignore: bool,
53 pub max_file_bytes: u64,
55 pub header_policy: HeaderPolicy,
57 pub languages: LanguageSelection,
59 pub generated_markers: GeneratedMarkers,
61 pub compile_commands: Option<PathBuf>,
64 pub follow_links: bool,
66}
67
68impl Default for DiscoveryConfig {
69 fn default() -> Self {
70 Self {
71 respect_gitignore: true,
72 max_file_bytes: DEFAULT_MAX_FILE_BYTES,
73 header_policy: HeaderPolicy::default(),
74 languages: LanguageSelection::default(),
75 generated_markers: GeneratedMarkers::default(),
76 compile_commands: None,
77 follow_links: false,
78 }
79 }
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Eq)]
84pub struct SkipReport {
85 pub too_large: u64,
87 pub binary: u64,
89 pub unreadable: u64,
91 pub language_excluded: u64,
93 pub symlinks: u64,
95 pub symlink_files: u64,
97 pub symlink_directories: u64,
99 pub walk_errors: u64,
101}
102
103impl SkipReport {
104 #[must_use]
106 pub const fn total(&self) -> u64 {
107 self.too_large
108 + self.binary
109 + self.unreadable
110 + self.language_excluded
111 + self.symlinks
112 + self.walk_errors
113 }
114}
115
116#[derive(Debug, Clone)]
118pub struct DiscoveryReport {
119 pub units: Vec<SourceUnit>,
121 pub build_variant: BuildVariant,
123 pub header_language: Language,
127 pub packages: Vec<PackageInfo>,
129 pub suppressed_generated: Vec<PathBuf>,
131 pub skipped: SkipReport,
133 pub compile_commands: Option<CompileCommands>,
135 pub compile_commands_error: Option<CompileCommandsDiagnostic>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct CompileCommandsDiagnostic {
143 pub path: PathBuf,
145 pub message: String,
147}
148
149#[derive(Debug, thiserror::Error)]
151pub enum DiscoveryError {
152 #[error("resolving scan root {path}: {source}")]
154 Root {
155 path: PathBuf,
157 #[source]
159 source: std::io::Error,
160 },
161}
162
163#[allow(
173 clippy::too_many_lines,
174 reason = "discovery keeps traversal accounting and the single-read source handoff together"
175)]
176pub fn discover(root: &Path, config: &DiscoveryConfig) -> Result<DiscoveryReport, DiscoveryError> {
177 let root = crate::paths::canonical(root).map_err(|source| DiscoveryError::Root {
178 path: root.to_path_buf(),
179 source,
180 })?;
181
182 let settings = WalkSettings {
183 respect_gitignore: config.respect_gitignore,
184 max_file_bytes: config.max_file_bytes,
185 header_policy: config.header_policy,
186 selection: config.languages,
187 follow_links: config.follow_links,
188 };
189 let walked = walk::collect(&root, &settings);
190 let mut skipped = SkipReport {
191 too_large: walked.too_large,
192 language_excluded: walked.language_excluded,
193 symlinks: walked.symlinks,
194 symlink_files: walked.symlink_files,
195 symlink_directories: walked.symlink_directories,
196 walk_errors: walked.walk_errors,
197 ..SkipReport::default()
198 };
199 let header_evidence = walked.evidence.verdict();
200 let manifests = walked.manifests;
201 let compile_candidates = walked.compile_commands;
202 let mut loaded = Vec::new();
207 for candidate in walked.candidates {
208 match std::fs::read(&candidate.absolute_path) {
209 Ok(bytes) => loaded.push((candidate, bytes)),
210 Err(_) => skipped.unreadable += 1,
211 }
212 }
213 let header_language = match config.header_policy {
214 HeaderPolicy::C => Language::C,
215 HeaderPolicy::Cpp => Language::Cpp,
216 HeaderPolicy::Detect => header_evidence.unwrap_or_else(|| headers_read_alone(&loaded)),
217 };
218 let layout = CargoLayout::from_manifests(&manifests);
219 let compile_commands_path = config.compile_commands.as_ref().map_or_else(
220 || select_compile_commands(&root, compile_candidates),
221 |path| Some(resolve_compile_commands_path(&root, path)),
222 );
223 let (compile_commands, compile_commands_error) = compile_commands_path.map_or_else(
224 || (None, None),
225 |path| match CompileCommands::read_with_limit(&path, config.max_file_bytes) {
226 Ok(database) => (Some(database), None),
227 Err(error) => (
228 None,
229 Some(CompileCommandsDiagnostic {
230 path,
231 message: error.to_string(),
232 }),
233 ),
234 },
235 );
236
237 let mut units = Vec::new();
238 let mut suppressed_generated = Vec::new();
239
240 for (candidate, bytes) in loaded {
241 let classification = candidate.classification.settled(header_language);
242 if !config.languages.includes(classification.language) {
245 skipped.language_excluded += 1;
246 continue;
247 }
248 let head = &bytes[..bytes.len().min(HEAD_BYTES)];
249 if head.contains(&0) {
250 skipped.binary += 1;
251 continue;
252 }
253 if config
254 .generated_markers
255 .is_generated(&String::from_utf8_lossy(head))
256 {
257 suppressed_generated.push(candidate.relative_path);
258 continue;
259 }
260 let content_hash = ContentHash::of(&bytes);
261 let (package, target_kind) = layout.classify(&candidate.absolute_path);
262 let crate_name = layout.crate_name(&candidate.absolute_path);
263 units.push(SourceUnit {
264 relative_path: candidate.relative_path,
265 absolute_path: candidate.absolute_path,
266 language: classification.language,
267 is_header: classification.is_header,
268 content_hash,
269 byte_len: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
270 source_bytes: Arc::from(bytes),
271 package,
272 crate_name,
273 target_kind,
274 });
275 }
276
277 units.sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
278 suppressed_generated.sort();
279
280 Ok(DiscoveryReport {
281 units,
282 build_variant: BuildVariant::fast(config.languages, header_language),
283 header_language,
284 packages: layout.packages(),
285 suppressed_generated,
286 skipped,
287 compile_commands,
288 compile_commands_error,
289 })
290}
291
292fn resolve_compile_commands_path(root: &Path, path: &Path) -> PathBuf {
293 if path.is_absolute() {
294 path.to_path_buf()
295 } else {
296 root.join(path)
297 }
298}
299
300fn select_compile_commands(root: &Path, mut candidates: Vec<PathBuf>) -> Option<PathBuf> {
301 candidates.sort_by(|left, right| {
302 compile_commands_depth(root, left)
303 .cmp(&compile_commands_depth(root, right))
304 .then_with(|| left.cmp(right))
305 });
306 candidates.into_iter().next()
307}
308
309fn compile_commands_depth(root: &Path, path: &Path) -> usize {
310 path.strip_prefix(root)
311 .map_or(usize::MAX, |relative| relative.components().count())
312}
313
314fn headers_read_alone(candidates: &[(walk::Candidate, Vec<u8>)]) -> Language {
324 for (candidate, bytes) in candidates {
325 if !candidate.classification.provisional {
326 continue;
327 }
328 if language::speaks_cpp(&String::from_utf8_lossy(bytes)) {
329 return Language::Cpp;
330 }
331 }
332 Language::C
333}
334
335#[cfg(test)]
336#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
337mod tests {
338 use super::*;
339 use std::fs;
340
341 fn fixture() -> (tempfile::TempDir, PathBuf) {
344 let dir = tempfile::tempdir().unwrap();
345 let root = dir.path().to_path_buf();
346 fs::write(root.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
347 fs::create_dir_all(root.join("src")).unwrap();
348 fs::write(root.join("src/lib.rs"), "pub fn a() {}\n").unwrap();
349 fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
350 (dir, root)
351 }
352
353 #[test]
354 fn enumerates_sources_with_package_and_target_attribution() {
355 let (_guard, root) = fixture();
356 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
357 assert_eq!(report.units.len(), 2);
358 assert_eq!(report.packages.len(), 1);
359 assert_eq!(report.packages[0].name, "demo");
360
361 let lib = report
362 .units
363 .iter()
364 .find(|u| u.relative_path == Path::new("src/lib.rs"))
365 .unwrap();
366 assert_eq!(lib.language, Language::Rust);
367 assert_eq!(lib.package.as_deref(), Some("demo"));
368 assert_eq!(lib.target_kind, TargetKind::Library);
369
370 let main = report
371 .units
372 .iter()
373 .find(|u| u.relative_path == Path::new("src/main.rs"))
374 .unwrap();
375 assert_eq!(main.target_kind, TargetKind::Binary);
376 }
377
378 #[test]
379 fn source_bytes_are_the_bytes_the_discovery_hash_describes() {
380 let (_guard, root) = fixture();
381 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
382 for unit in &report.units {
383 assert_eq!(unit.content_hash, ContentHash::of(&unit.source_bytes));
384 }
385 }
386
387 #[test]
392 fn a_unit_carries_the_crate_a_compiler_knows_it_by() {
393 let dir = tempfile::tempdir().unwrap();
394 let root = dir.path().to_path_buf();
395 fs::write(root.join("Cargo.toml"), "[package]\nname = \"my-demo\"\n").unwrap();
396 fs::create_dir_all(root.join("src")).unwrap();
397 fs::write(root.join("src/lib.rs"), "pub fn a() {}\n").unwrap();
398
399 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
400 let lib = &report.units[0];
401 assert_eq!(lib.package.as_deref(), Some("my-demo"));
402 assert_eq!(lib.crate_name.as_deref(), Some("my_demo"));
403 }
404
405 #[test]
406 fn units_are_ordered_by_relative_path() {
407 let (_guard, root) = fixture();
408 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
409 let paths: Vec<_> = report.units.iter().map(|u| &u.relative_path).collect();
410 let mut sorted = paths.clone();
411 sorted.sort();
412 assert_eq!(paths, sorted);
413 }
414
415 #[test]
416 fn generated_files_are_suppressed_and_counted_not_dropped() {
417 let (_guard, root) = fixture();
418 fs::write(root.join("src/gen.rs"), "// @generated\npub fn g() {}\n").unwrap();
419 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
420 assert!(
421 report
422 .units
423 .iter()
424 .all(|u| u.relative_path != Path::new("src/gen.rs"))
425 );
426 assert_eq!(
427 report.suppressed_generated,
428 vec![PathBuf::from("src/gen.rs")]
429 );
430 }
431
432 #[test]
437 fn bindings_are_suppressed_though_their_banner_follows_no_convention() {
438 let (_guard, root) = fixture();
439 fs::write(
440 root.join("src/bindings.rs"),
441 "/* automatically generated by rust-bindgen 0.72.1 */\npub fn b() {}\n",
442 )
443 .unwrap();
444 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
445 assert_eq!(
446 report.suppressed_generated,
447 vec![PathBuf::from("src/bindings.rs")]
448 );
449 }
450
451 #[test]
452 fn binary_files_are_skipped() {
453 let (_guard, root) = fixture();
454 fs::write(root.join("src/blob.rs"), [0u8, 1, 2, 3, 0]).unwrap();
455 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
456 assert_eq!(report.skipped.binary, 1);
457 assert!(
458 report
459 .units
460 .iter()
461 .all(|u| u.relative_path != Path::new("src/blob.rs"))
462 );
463 }
464
465 #[test]
466 fn oversized_files_are_skipped() {
467 let (_guard, root) = fixture();
468 fs::write(root.join("src/big.rs"), vec![b'x'; 4096]).unwrap();
469 let config = DiscoveryConfig {
470 max_file_bytes: 1024,
471 ..DiscoveryConfig::default()
472 };
473 let report = discover(&root, &config).unwrap();
474 assert_eq!(report.skipped.too_large, 1);
475 }
476
477 #[test]
478 fn oversized_metadata_inputs_are_skipped_before_they_are_read() {
479 let (_guard, root) = fixture();
480 fs::write(root.join("Cargo.toml"), "x".repeat(4096)).unwrap();
481 fs::write(root.join("compile_commands.json"), "[{}]".repeat(1024)).unwrap();
482 let config = DiscoveryConfig {
483 max_file_bytes: 1024,
484 ..DiscoveryConfig::default()
485 };
486
487 let report = discover(&root, &config).unwrap();
488
489 assert!(report.packages.is_empty());
490 assert!(report.compile_commands.is_none());
491 assert!(report.compile_commands_error.is_none());
492 assert_eq!(report.skipped.too_large, 2);
493 }
494
495 #[test]
496 fn an_oversized_explicit_compilation_database_is_reported_without_reading_it() {
497 let (_guard, root) = fixture();
498 let database = root.join("commands.json");
499 fs::write(&database, "[{}]".repeat(1024)).unwrap();
500 let config = DiscoveryConfig {
501 max_file_bytes: 1024,
502 compile_commands: Some(PathBuf::from("commands.json")),
503 ..DiscoveryConfig::default()
504 };
505
506 let report = discover(&root, &config).unwrap();
507
508 assert!(report.compile_commands.is_none());
509 assert_eq!(report.skipped.too_large, 1);
510 assert_eq!(
511 report
512 .compile_commands_error
513 .as_ref()
514 .map(|diagnostic| diagnostic.message.as_str()),
515 Some("compile_commands.json is 4096 bytes, exceeding the 1024-byte limit")
516 );
517 }
518
519 #[cfg(unix)]
522 #[test]
523 fn discovers_a_root_compilation_database_symlink_into_an_ignored_build_directory() {
524 let (_guard, root) = fixture();
525 let build = root.join("build");
526 fs::create_dir_all(&build).unwrap();
527 fs::write(root.join(".gitignore"), "build/\n").unwrap();
528 fs::write(
529 build.join("compile_commands.json"),
530 r#"[{"directory":"/work","file":"/work/src/main.cpp","arguments":["clang++","-c","/work/src/main.cpp"]}]"#,
531 )
532 .unwrap();
533 std::os::unix::fs::symlink(
534 "build/compile_commands.json",
535 root.join("compile_commands.json"),
536 )
537 .unwrap();
538
539 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
540 assert_eq!(
541 report.compile_commands.as_ref().map(|db| db.entries.len()),
542 Some(1)
543 );
544 }
545
546 #[test]
547 fn an_explicit_compilation_database_overrides_automatic_discovery() {
548 let (_guard, root) = fixture();
549 fs::write(
550 root.join("compile_commands.json"),
551 r#"[{"directory":"/work","file":"/work/automatic.cpp"}]"#,
552 )
553 .unwrap();
554 let explicit = root.join("build/commands.json");
555 fs::create_dir_all(explicit.parent().unwrap()).unwrap();
556 fs::write(
557 &explicit,
558 r#"[{"directory":"/work","file":"/work/explicit.cpp"}]"#,
559 )
560 .unwrap();
561 let config = DiscoveryConfig {
562 compile_commands: Some(PathBuf::from("build/commands.json")),
563 ..DiscoveryConfig::default()
564 };
565
566 let report = discover(&root, &config).unwrap();
567 assert_eq!(
568 report
569 .compile_commands
570 .as_ref()
571 .and_then(|db| db.entries.first())
572 .map(|entry| entry.file.as_path()),
573 Some(Path::new("/work/explicit.cpp"))
574 );
575 }
576
577 #[test]
578 fn automatic_compilation_database_selection_is_shallow_then_lexical() {
579 let root = Path::new("/work");
580 let selected = select_compile_commands(
581 root,
582 vec![
583 root.join("z/compile_commands.json"),
584 root.join("a/compile_commands.json"),
585 root.join("nested/a/compile_commands.json"),
586 ],
587 );
588 assert_eq!(selected, Some(root.join("a/compile_commands.json")));
589 }
590
591 #[cfg(unix)]
592 #[test]
593 fn symlinks_are_counted_without_reading_or_following_them() {
594 use std::os::unix::fs::symlink;
595
596 let (_guard, root) = fixture();
597 let target = root.join("src/lib.rs");
598 symlink(&target, root.join("src/linked.rs")).unwrap();
599
600 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
601 assert_eq!(report.skipped.symlinks, 1);
602 assert_eq!(report.skipped.symlink_files, 1);
603 assert_eq!(report.skipped.symlink_directories, 0);
604 assert_eq!(report.skipped.total(), 1);
605 assert!(
606 report
607 .units
608 .iter()
609 .all(|unit| unit.relative_path != Path::new("src/linked.rs"))
610 );
611 }
612
613 #[cfg(unix)]
614 #[test]
615 fn untracked_link_directories_are_counted_separately_from_linked_files() {
616 use std::os::unix::fs::symlink;
617
618 let (_guard, root) = fixture();
619 symlink(root.join("src"), root.join("linked-src")).unwrap();
620 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
621 assert_eq!(report.skipped.symlinks, 1);
622 assert_eq!(report.skipped.symlink_files, 0);
623 assert_eq!(report.skipped.symlink_directories, 1);
624 }
625
626 #[cfg(unix)]
627 #[test]
628 fn every_walked_source_is_accounted_for_by_one_discovery_outcome() {
629 use std::os::unix::fs::symlink;
630
631 let (_guard, root) = fixture();
632 fs::write(root.join("src/util.c"), "int value(void) { return 1; }\n").unwrap();
633 fs::write(
634 root.join("src/generated.rs"),
635 "// @generated\nfn generated() {}\n",
636 )
637 .unwrap();
638 fs::write(root.join("src/binary.rs"), b"fn binary() {}\0").unwrap();
639 symlink(root.join("src/lib.rs"), root.join("src/linked.rs")).unwrap();
640 let report = discover(
641 &root,
642 &DiscoveryConfig {
643 languages: LanguageSelection {
644 rust: true,
645 c: false,
646 cpp: false,
647 },
648 ..DiscoveryConfig::default()
649 },
650 )
651 .unwrap();
652 let accounted = report.units.len()
653 + report.suppressed_generated.len()
654 + usize::try_from(
655 report.skipped.language_excluded + report.skipped.binary + report.skipped.symlinks,
656 )
657 .unwrap();
658 assert_eq!(accounted, 6, "every walked source reached one outcome");
659 }
660
661 #[cfg(unix)]
662 #[test]
663 fn following_links_includes_a_linked_source_without_losing_the_target() {
664 use std::os::unix::fs::symlink;
665
666 let (_guard, root) = fixture();
667 let target = root.join("src/lib.rs");
668 symlink(&target, root.join("src/linked.rs")).unwrap();
669 let report = discover(
670 &root,
671 &DiscoveryConfig {
672 follow_links: true,
673 ..DiscoveryConfig::default()
674 },
675 )
676 .unwrap();
677 let paths: Vec<_> = report
678 .units
679 .iter()
680 .map(|unit| unit.relative_path.as_path())
681 .collect();
682 assert!(paths.contains(&Path::new("src/lib.rs")));
683 assert!(paths.contains(&Path::new("src/linked.rs")));
684 assert_eq!(report.skipped.symlinks, 0);
685 }
686
687 #[cfg(unix)]
688 #[test]
689 fn following_a_directory_cycle_terminates_and_accounts_for_the_walk_error() {
690 use std::os::unix::fs::symlink;
691
692 let (_guard, root) = fixture();
693 symlink(&root, root.join("src/cycle")).unwrap();
694 let report = discover(
695 &root,
696 &DiscoveryConfig {
697 follow_links: true,
698 ..DiscoveryConfig::default()
699 },
700 )
701 .unwrap();
702 assert!(
703 report.skipped.walk_errors > 0,
704 "the walker must report a detected symlink cycle"
705 );
706 assert!(
707 report
708 .units
709 .iter()
710 .any(|unit| unit.relative_path == Path::new("src/lib.rs"))
711 );
712 }
713
714 #[test]
715 fn no_ignore_includes_dot_paths() {
716 let (_guard, root) = fixture();
717 fs::create_dir_all(root.join(".generated")).unwrap();
718 fs::write(root.join(".generated/extra.rs"), "pub fn extra() {}\n").unwrap();
719
720 let default = discover(&root, &DiscoveryConfig::default()).unwrap();
721 assert!(
722 default
723 .units
724 .iter()
725 .all(|unit| unit.relative_path != Path::new(".generated/extra.rs"))
726 );
727
728 let report = discover(
729 &root,
730 &DiscoveryConfig {
731 respect_gitignore: false,
732 ..DiscoveryConfig::default()
733 },
734 )
735 .unwrap();
736 assert!(
737 report
738 .units
739 .iter()
740 .any(|unit| unit.relative_path == Path::new(".generated/extra.rs"))
741 );
742 }
743
744 #[test]
745 fn language_selection_excludes_disabled_languages() {
746 let (_guard, root) = fixture();
747 fs::write(root.join("src/util.c"), "int a(void){return 0;}\n").unwrap();
748 let config = DiscoveryConfig {
749 languages: LanguageSelection {
750 rust: true,
751 c: false,
752 cpp: false,
753 },
754 ..DiscoveryConfig::default()
755 };
756 let report = discover(&root, &config).unwrap();
757 assert!(report.units.iter().all(|u| u.language == Language::Rust));
758 assert_eq!(report.skipped.language_excluded, 1);
759 }
760
761 fn tree_of(names: &[&str]) -> (tempfile::TempDir, PathBuf) {
763 let dir = tempfile::tempdir().unwrap();
764 let root = dir.path().to_path_buf();
765 for name in names {
766 fs::write(root.join(name), "int a(void){return 0;}\n").unwrap();
767 }
768 (dir, root)
769 }
770
771 fn language_of(names: &[&str], name: &str) -> Language {
773 let (_guard, root) = tree_of(names);
774 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
775 report
776 .units
777 .iter()
778 .find(|unit| unit.relative_path == Path::new(name))
779 .unwrap_or_else(|| panic!("{name} was not discovered"))
780 .language
781 }
782
783 #[test]
784 fn a_bare_header_follows_the_language_the_tree_is_written_in() {
785 assert_eq!(
786 language_of(&["a.cpp", "b.cpp", "x.h"], "x.h"),
787 Language::Cpp
788 );
789 assert_eq!(language_of(&["a.c", "b.c", "x.h"], "x.h"), Language::C);
790 }
791
792 #[test]
793 fn a_cpp_only_spelling_after_a_long_header_preamble_settles_the_dialect() {
794 let (_guard, root) = tree_of(&["only.h"]);
795 let preamble = "license line\n".repeat(HEAD_BYTES);
796 fs::write(
797 root.join("only.h"),
798 format!("/* {preamble} */\nnamespace audit {{ struct Entry {{}}; }}\n"),
799 )
800 .unwrap();
801
802 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
803 assert_eq!(report.header_language, Language::Cpp);
804 assert_eq!(report.build_variant.headers, Some(Language::Cpp));
805 }
806
807 #[test]
808 fn the_settled_header_language_is_reported_and_carried_by_the_variant() {
809 let (_guard, root) = tree_of(&["a.cpp", "x.h"]);
810 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
811 assert_eq!(report.header_language, Language::Cpp);
812 assert_eq!(report.build_variant.headers, Some(Language::Cpp));
813 }
814
815 #[test]
816 fn an_explicit_header_policy_overrides_what_the_tree_suggests() {
817 let (_guard, root) = tree_of(&["a.cpp", "b.cpp", "x.h"]);
818 let config = DiscoveryConfig {
819 header_policy: HeaderPolicy::C,
820 ..DiscoveryConfig::default()
821 };
822 let report = discover(&root, &config).unwrap();
823 let header = report
824 .units
825 .iter()
826 .find(|unit| unit.relative_path == Path::new("x.h"))
827 .unwrap();
828 assert_eq!(
829 header.language,
830 Language::C,
831 "the policy decided, not the tree"
832 );
833 assert_eq!(report.header_language, Language::C);
836 assert_eq!(report.build_variant.headers, Some(Language::C));
837 }
838
839 #[test]
840 fn a_header_settled_into_a_disabled_language_is_left_out() {
841 let (_guard, root) = tree_of(&["a.cpp", "b.cpp", "x.h", "plain.c"]);
844 let config = DiscoveryConfig {
845 languages: LanguageSelection {
846 rust: true,
847 c: true,
848 cpp: false,
849 },
850 ..DiscoveryConfig::default()
851 };
852 let report = discover(&root, &config).unwrap();
853 let paths: Vec<&Path> = report
854 .units
855 .iter()
856 .map(|unit| unit.relative_path.as_path())
857 .collect();
858 assert_eq!(
859 paths,
860 vec![Path::new("plain.c")],
861 "the header settled on C++, which this run does not analyse"
862 );
863 assert_eq!(report.skipped.language_excluded, 3);
864 }
865
866 #[test]
867 fn every_unit_shares_the_fast_build_variant() {
868 let (_guard, root) = fixture();
869 let report = discover(&root, &DiscoveryConfig::default()).unwrap();
870 assert_eq!(report.build_variant.mode, AnalysisMode::Fast);
871 assert_eq!(
872 report.build_variant.normalization_version,
873 NORMALIZATION_VERSION
874 );
875 }
876
877 #[test]
878 fn missing_root_is_an_error() {
879 let dir = tempfile::tempdir().unwrap();
880 let missing = dir.path().join("does-not-exist");
881 assert!(matches!(
882 discover(&missing, &DiscoveryConfig::default()),
883 Err(DiscoveryError::Root { .. })
884 ));
885 }
886}