Skip to main content

codehelion_core/
discovery.rs

1//! Project discovery: turning a directory tree into the set of source units the
2//! Fast source-audit engine will analyse.
3//!
4//! Discovery is filesystem-only. It reads source files, Cargo manifests and, if
5//! present, a `compile_commands.json`; it never executes build scripts,
6//! procedural macros or any target code. It is also where files are
7//! pre-suppressed: generated files, binary files and files over the size
8//! ceiling are excluded before any clone candidate is generated, and every
9//! excluded file is accounted for so nothing is dropped silently. Each result
10//! is attributed to a single implicit [`BuildVariant`], so results from
11//! different variants are never conflated.
12
13mod 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
38/// Default per-file size ceiling, in bytes.
39///
40/// Files larger than this are skipped: they are almost always generated tables
41/// or vendored blobs, and pairing every fragment of a multi-megabyte file
42/// dominates the candidate budget.
43pub const DEFAULT_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
44
45/// Bytes of a file's head inspected for the binary check and generated markers.
46const HEAD_BYTES: usize = 8 * 1024;
47
48/// Settings that control a discovery run.
49#[derive(Debug, Clone)]
50pub struct DiscoveryConfig {
51    /// Honour `.gitignore` and related ignore files (default `true`).
52    pub respect_gitignore: bool,
53    /// Per-file size ceiling in bytes.
54    pub max_file_bytes: u64,
55    /// How to classify bare `.h` headers.
56    pub header_policy: HeaderPolicy,
57    /// Languages to enumerate.
58    pub languages: LanguageSelection,
59    /// Markers that flag a file as generated.
60    pub generated_markers: GeneratedMarkers,
61    /// Compilation database to use instead of an automatically discovered
62    /// `compile_commands.json`.
63    pub compile_commands: Option<PathBuf>,
64    /// Whether the source walker follows symbolic links.
65    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/// Counts of files excluded for reasons other than being generated.
83#[derive(Debug, Clone, Default, PartialEq, Eq)]
84pub struct SkipReport {
85    /// Files past the size ceiling.
86    pub too_large: u64,
87    /// Files that looked binary (a NUL byte in their head).
88    pub binary: u64,
89    /// Files that could not be read.
90    pub unreadable: u64,
91    /// Source files excluded because their language was disabled.
92    pub language_excluded: u64,
93    /// Symbolic links deliberately left unresolved by the walker.
94    pub symlinks: u64,
95    /// Symbolic-link files deliberately left unresolved by the walker.
96    pub symlink_files: u64,
97    /// Symbolic-link directories deliberately left unresolved by the walker.
98    pub symlink_directories: u64,
99    /// Directory entries the walker could not read.
100    pub walk_errors: u64,
101}
102
103impl SkipReport {
104    /// Total number of skipped entries.
105    #[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/// The outcome of a discovery run.
117#[derive(Debug, Clone)]
118pub struct DiscoveryReport {
119    /// Source units selected for analysis, ordered by relative path.
120    pub units: Vec<SourceUnit>,
121    /// The implicit build variant every unit is attributed to.
122    pub build_variant: BuildVariant,
123    /// The language bare `.h` headers were read as: what the configured
124    /// [`HeaderPolicy`] named, or what the tree pointed to when the policy
125    /// left it to detection.
126    pub header_language: Language,
127    /// Cargo packages recognised in the tree, ordered by name.
128    pub packages: Vec<PackageInfo>,
129    /// Relative paths excluded because they are generated, ordered by path.
130    pub suppressed_generated: Vec<PathBuf>,
131    /// Counts of files skipped for other reasons.
132    pub skipped: SkipReport,
133    /// Parsed compilation database, if one was found and read successfully.
134    pub compile_commands: Option<CompileCommands>,
135    /// A compilation database found during discovery but not usable for
136    /// semantic analysis.
137    pub compile_commands_error: Option<CompileCommandsDiagnostic>,
138}
139
140/// A compilation database that discovery found but could not read.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct CompileCommandsDiagnostic {
143    /// The database path selected during discovery.
144    pub path: PathBuf,
145    /// The user-facing reason the database could not be used.
146    pub message: String,
147}
148
149/// A failure that prevents discovery from running at all.
150#[derive(Debug, thiserror::Error)]
151pub enum DiscoveryError {
152    /// The scan root does not exist or could not be resolved.
153    #[error("resolving scan root {path}: {source}")]
154    Root {
155        /// The path that could not be resolved.
156        path: PathBuf,
157        /// The underlying I/O error.
158        #[source]
159        source: std::io::Error,
160    },
161}
162
163/// Discover the source units under `root`.
164///
165/// The tree is traversed once. Generated, binary and oversized files are
166/// excluded and accounted for; the returned [`DiscoveryReport`] lists the
167/// selected units in a deterministic order.
168///
169/// # Errors
170///
171/// Returns [`DiscoveryError`] if `root` cannot be resolved to a directory.
172#[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    // Settle the bare `.h` headers before anything reads them: the grammar a
203    // header is parsed with is part of the build variant, so it is one
204    // decision for the whole run rather than a per-file guess. An explicit
205    // policy is the answer where there is one; detection only fills the gap.
206    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        // The walk let a header through while either C or C++ was enabled,
243        // because it did not yet know which one it was.
244        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
314/// Settle bare `.h` headers from the headers themselves, for a tree that has
315/// nothing else to settle them from.
316///
317/// Reached only when no `.c`, `.cpp` or unambiguously-extended header was
318/// found at all, which in practice means a header-only library: every line the
319/// run will read is in these files, so the grammar is the whole result rather
320/// than a detail of it. Each header is read for a C++-only spelling and the
321/// first one that speaks decides — `language::speaks_cpp` says why one is
322/// enough, and why C is the answer when none of them says otherwise.
323fn 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    /// Build a small tree and return its root. The temp dir is returned too so
342    /// the caller keeps it alive for the duration of the test.
343    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    /// Discovery is where the package layout is read, so it is where a file
388    /// learns the crate a compiler would be asked about. The package name and
389    /// the crate name are spelled differently here, so a unit that carried the
390    /// package instead would fail rather than pass by looking alike.
391    #[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    /// The binding generators are what makes a foreign-function crate mostly
433    /// machine output, and none of them writes the banner the code generators
434    /// settled on. A tree of bindings that reaches the units is a tree whose
435    /// whole report is about the generator.
436    #[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    /// `CMake` commonly leaves its database in an ignored build directory and
520    /// exposes it through this root-level symlink for editor tooling.
521    #[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    /// A tree holding `names`, each an empty-but-valid source file.
762    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    /// The language discovery settled on for `name` in a tree of `names`.
772    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        // What the run reports and attributes its results to is the grammar it
834        // used, not the one it would have chosen unaided.
835        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        // The walk keeps a `.h` while either C or C++ is enabled, because it
842        // does not yet know which it is. Once settled, the selection applies.
843        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}