Skip to main content

supercov_engine/
go_project.rs

1//! Go project discovery: what Supercov measures, and what it deliberately does not.
2//!
3//! Go settles two questions other languages leave ambiguous. A test file is
4//! exactly one ending in `_test.go` — the toolchain enforces it, so there is no
5//! heuristic to get wrong. And `vendor/` and `testdata/` have meanings fixed by
6//! the toolchain: vendored code is someone else's, and the compiler ignores
7//! `testdata` entirely, so measuring either would put obligations on code this
8//! project's tests were never meant to exercise.
9
10use std::collections::BTreeSet;
11use std::path::{Path, PathBuf};
12
13use crate::coverage_report::CoverageManifest;
14use crate::go_instrumenter::GoProbe;
15use crate::integrity::ExplicitIntegrityInputs;
16
17/// Directories whose contents are never this project's measured source.
18///
19/// `vendor` and `testdata` are toolchain-defined. The rest are conventional
20/// build and tooling output that happens to contain `.go` files.
21const EXCLUDED_DIRECTORIES: &[&str] = &[
22    ".git",
23    ".idea",
24    ".vscode",
25    "bin",
26    "node_modules",
27    "testdata",
28    "third_party",
29    "vendor",
30];
31
32/// Files that pin what the build resolves to.
33const DEPENDENCY_FILES: &[&str] = &[
34    "go.mod",
35    "go.sum",
36    "go.work",
37    "go.work.sum",
38    "vendor/modules.txt",
39];
40
41/// Configuration that decides what actually executes.
42const CONFIGURATION_FILES: &[&str] = &[".go-version", "go.env"];
43
44#[derive(Debug, Clone, PartialEq, Eq, Default)]
45pub struct GoFiles {
46    /// Relative, `/`-separated paths of measured application sources.
47    pub sources: Vec<String>,
48    /// Relative paths of `_test.go` files.
49    pub tests: Vec<String>,
50    pub dependency_files: Vec<PathBuf>,
51    pub configuration_files: Vec<PathBuf>,
52    pub excluded: Vec<(String, &'static str)>,
53}
54
55#[derive(Debug, Clone, PartialEq)]
56pub struct PreparedGoProject {
57    pub root: PathBuf,
58    pub files: GoFiles,
59    pub manifest: CoverageManifest,
60    pub probes: std::collections::BTreeMap<u64, GoProbe>,
61    /// Each measured source and what it becomes once instrumented, in
62    /// discovery order. Carried rather than recomputed: preparing the
63    /// obligations already parsed and rewrote every file, and parsing a
64    /// project twice to get the same answer is the kind of cost that shows up
65    /// as a slow tool with no explanation.
66    pub instrumented: Vec<(String, String)>,
67    /// Conditions per decision, indexed the way the runtime indexes its
68    /// decision state.
69    pub decision_widths: Vec<u8>,
70    /// Files that did not parse, with the reason. They are reported rather
71    /// than skipped silently: a file Supercov cannot read is a hole in the
72    /// denominator, and a hole nobody is told about is a wrong number.
73    pub unparseable: Vec<(String, String)>,
74}
75
76/// A Go test file is exactly one whose name ends `_test.go`. The toolchain
77/// enforces this, so unlike every other language there is no guessing here.
78pub fn is_test_file(relative: &str) -> bool {
79    relative
80        .rsplit('/')
81        .next()
82        .is_some_and(|name| name.ends_with("_test.go"))
83}
84
85/// The module directories a `go.work` declares, relative to the workspace root
86/// and `/`-separated.
87///
88/// Empty when there is no workspace, which is also the answer for a repository
89/// that simply has one module at its root.
90pub fn workspace_modules(root: &Path) -> BTreeSet<String> {
91    let Ok(text) = std::fs::read_to_string(root.join("go.work")) else {
92        return BTreeSet::new();
93    };
94    let mut modules = BTreeSet::new();
95    let mut in_block = false;
96    for line in text.lines() {
97        let line = line.split("//").next().unwrap_or_default().trim();
98        if line.is_empty() {
99            continue;
100        }
101        // `use ./a`, or a `use (` block of one directory per line.
102        let entry = if in_block {
103            if line == ")" {
104                in_block = false;
105                continue;
106            }
107            Some(line)
108        } else if let Some(rest) = line.strip_prefix("use ") {
109            let rest = rest.trim();
110            if rest == "(" {
111                in_block = true;
112                continue;
113            }
114            Some(rest)
115        } else {
116            if line.starts_with("use(") {
117                in_block = true;
118            }
119            None
120        };
121        if let Some(entry) = entry {
122            let entry = entry.trim_matches('"').trim();
123            let entry = entry.strip_prefix("./").unwrap_or(entry);
124            let entry = entry.trim_end_matches('/');
125            if !entry.is_empty() {
126                modules.insert(if entry == "." {
127                    ".".to_owned()
128                } else {
129                    entry.replace('\\', "/")
130                });
131            }
132        }
133    }
134    modules
135}
136
137fn walk(root: &Path, directory: &Path, files: &mut GoFiles) -> Result<(), String> {
138    let members = workspace_modules(root);
139    let entries = std::fs::read_dir(directory)
140        .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
141    let mut sorted = entries
142        .collect::<Result<Vec<_>, _>>()
143        .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
144    sorted.sort_by_key(std::fs::DirEntry::path);
145    for entry in sorted {
146        let path = entry.path();
147        let Ok(relative) = path.strip_prefix(root) else {
148            continue;
149        };
150        let relative = relative.to_string_lossy().replace('\\', "/");
151        let name = path
152            .file_name()
153            .map(|name| name.to_string_lossy().into_owned())
154            .unwrap_or_default();
155        let file_type = entry
156            .file_type()
157            .map_err(|error| format!("could not inspect {}: {error}", path.display()))?;
158        if file_type.is_dir() {
159            if EXCLUDED_DIRECTORIES.contains(&name.as_str()) || name.starts_with('.') {
160                files
161                    .excluded
162                    .push((relative, "tooling or vendored directory"));
163                continue;
164            }
165            // The toolchain ignores a directory whose name begins with an
166            // underscore: `go build ./...` and `go test ./...` walk past it.
167            // chi keeps its examples in `_examples`, a tenth of its lines,
168            // and no command of the project's ever compiled them. Its files
169            // are named one by one so the scope can say where they went.
170            if name.starts_with('_') {
171                ignored_by_the_toolchain(root, &path, files)?;
172                continue;
173            }
174            // A directory with a go.mod of its own is a different module. The
175            // toolchain does not compile it as part of this one -- `go test
176            // ./...` walks straight past it -- so measuring it would put
177            // obligations in the denominator that no test here can reach, and
178            // writing a probe file there would import a runtime the nested
179            // module cannot resolve. That turns a build that worked into one
180            // that does not.
181            if path.join("go.mod").is_file() && !members.contains(&relative) {
182                files.excluded.push((relative, "a module of its own"));
183                continue;
184            }
185            walk(root, &path, files)?;
186            continue;
187        }
188        if !file_type.is_file() {
189            continue;
190        }
191        if DEPENDENCY_FILES.contains(&relative.as_str()) {
192            files.dependency_files.push(PathBuf::from(&relative));
193            continue;
194        }
195        if CONFIGURATION_FILES.contains(&name.as_str()) {
196            files.configuration_files.push(PathBuf::from(&relative));
197            continue;
198        }
199        if !name.ends_with(".go") {
200            continue;
201        }
202        // As for a directory: a file named `_draft.go` is not compiled.
203        if name.starts_with('_') || name.starts_with('.') {
204            files.excluded.push((relative, IGNORED_BY_THE_TOOLCHAIN));
205            continue;
206        }
207        // And so is one constrained to `ignore`: the convention for Go kept
208        // beside Go without being compiled -- a generator's `package main`,
209        // a reference harness. Measured, it is an obligation nothing can
210        // reach, and its package clause can name the directory's package
211        // for the probe file it shares with the real one.
212        if build_ignored(&read_source(&path)?) {
213            files.excluded.push((relative, IGNORED_BY_THE_TOOLCHAIN));
214            continue;
215        }
216        if is_test_file(&relative) {
217            files.tests.push(relative);
218        } else {
219            files.sources.push(relative);
220        }
221    }
222    Ok(())
223}
224
225const IGNORED_BY_THE_TOOLCHAIN: &str = "ignored by the Go toolchain";
226
227/// A Go file's text, for the constraints above its package clause.
228fn read_source(path: &Path) -> Result<String, String> {
229    let bytes = std::fs::read(path)
230        .map_err(|error| format!("could not read {}: {error}", path.display()))?;
231    Ok(String::from_utf8_lossy(&bytes).into_owned())
232}
233
234/// Whether the toolchain will refuse to build this file whatever it is asked.
235///
236/// Build constraints in general cannot be answered without knowing the GOOS,
237/// GOARCH and tags a build will use, and guessing at them would drop files a
238/// run does measure. `ignore` needs none of that: it is not a GOOS, not a
239/// GOARCH, and nothing defines it, which is exactly why it is the convention
240/// for Go kept beside Go without being compiled -- a reference harness, a
241/// generator's input. So a constraint that requires it can be read alone:
242/// one that merely mentions it (`ignore || linux`) is built on Linux.
243pub(crate) fn build_ignored(source: &str) -> bool {
244    // Constraints live above the package clause and nowhere else: the
245    // toolchain stops reading them there, so a `//go:build ignore` in a
246    // comment further down is text about Go rather than a constraint on it.
247    let header = if source.starts_with("package ") {
248        // Nothing precedes a clause on the first line, and looking for a
249        // newline before it would find none and read the whole file.
250        ""
251    } else {
252        source
253            .find("\npackage ")
254            .map_or(source, |clause| &source[..clause])
255    };
256    let terms = |expression: &str, and: &str| -> bool {
257        expression.split(and).any(|term| term.trim() == "ignore")
258    };
259    header.lines().map(str::trim).any(|line| {
260        if let Some(expression) = line.strip_prefix("//go:build") {
261            // Required only when it is a term of a plain conjunction.
262            !expression.contains("||") && !expression.contains('(') && terms(expression, "&&")
263        } else if let Some(options) = line.strip_prefix("// +build") {
264            // The spelling Go used before 1.17, still in trees today: spaces
265            // separate alternatives, commas join terms, lines join each other.
266            let options = options.split_whitespace().collect::<Vec<_>>();
267            options.len() == 1 && terms(options[0], ",")
268        } else {
269            false
270        }
271    })
272}
273
274/// Every `.go` file under a directory the toolchain ignores.
275fn ignored_by_the_toolchain(
276    root: &Path,
277    directory: &Path,
278    files: &mut GoFiles,
279) -> Result<(), String> {
280    let entries = std::fs::read_dir(directory)
281        .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
282    for entry in entries {
283        let entry =
284            entry.map_err(|error| format!("could not read {}: {error}", directory.display()))?;
285        let path = entry.path();
286        let file_type = entry
287            .file_type()
288            .map_err(|error| format!("could not inspect {}: {error}", path.display()))?;
289        if file_type.is_dir() {
290            ignored_by_the_toolchain(root, &path, files)?;
291        } else if file_type.is_file()
292            && path.extension().is_some_and(|extension| extension == "go")
293            && let Ok(relative) = path.strip_prefix(root)
294        {
295            files.excluded.push((
296                relative.to_string_lossy().replace('\\', "/"),
297                IGNORED_BY_THE_TOOLCHAIN,
298            ));
299        }
300    }
301    Ok(())
302}
303
304/// An example program at the top of the module: gorilla/websocket keeps eight
305/// under `examples/`, a quarter of its lines, and nats.go eighty-five. Each is
306/// a `package main` no test runs. Only the directory's name is read, and only
307/// `examples`: cobra's `doc` and nats.go's `bench` are packages of the product
308/// with tests of their own.
309fn example_program(file: &str) -> bool {
310    file.split_once('/').is_some_and(|(first, _)| {
311        matches!(first.to_ascii_lowercase().as_str(), "examples" | "example")
312    })
313}
314
315pub fn discover_go_files(root: &Path) -> Result<GoFiles, String> {
316    let mut files = GoFiles::default();
317    walk(root, root, &mut files)?;
318    files.sources.sort();
319    files.tests.sort();
320    files.dependency_files.sort();
321    files.configuration_files.sort();
322    files.excluded.sort();
323    Ok(files)
324}
325
326const LANGUAGE: &str = "go";
327
328/// A file the parser could not read is a hole in the denominator, and a hole
329/// nobody can see is worse than one they can. A diagnostic line scrolls past;
330/// this puts the file in the manifest, so it reaches the declaration's
331/// structural limitations and `supercov runs latest` can still name it long
332/// after the build log is gone.
333pub(crate) fn unparseable_limitation(file: &str, reason: &str) -> serde_json::Value {
334    serde_json::json!({
335        "id": crate::go_instrumenter::stable_obligation_id(LANGUAGE, file, "unparseable", 0, 0),
336        "kind": "file-does-not-parse",
337        "file": file,
338        // The surface is the whole file: there is no construct to quote,
339        // because nothing in it parsed.
340        "source": file,
341        "line": 1,
342        "column": 1,
343        "reason": format!(
344            "{reason}; the file carries no obligations and nothing in it counts towards this run"
345        ),
346    })
347}
348
349pub fn prepare_go_project(
350    root: &Path,
351    roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
352) -> Result<PreparedGoProject, String> {
353    let mut files = discover_go_files(root)?;
354    match roots {
355        Some(roots) => {
356            roots.narrow(&mut files.sources, &mut files.excluded, String::as_str);
357            roots.refuse_if_empty(files.sources.len(), "Go")?;
358        }
359        // A root someone named is what they say it is.
360        None => {
361            let GoFiles {
362                sources, excluded, ..
363            } = &mut files;
364            sources.retain(|file| {
365                let example = example_program(file);
366                if example {
367                    excluded.push((file.clone(), "example"));
368                }
369                !example
370            });
371            excluded.sort();
372        }
373    }
374    if files.sources.is_empty() && files.tests.is_empty() {
375        return Err(
376            "no Go source files were found under the project root; Supercov measures .go files outside vendor, testdata and tooling directories"
377                .into(),
378        );
379    }
380    let mut manifest = CoverageManifest {
381        decisions: Vec::new(),
382        points: Vec::new(),
383        branches: Vec::new(),
384        limitations: Vec::new(),
385        unmeasured: Vec::new(),
386        scope: None,
387        assertion_sites: Vec::new(),
388    };
389    let mut probes = std::collections::BTreeMap::new();
390    let mut instrumented = Vec::new();
391    let mut decision_widths = Vec::new();
392    let mut unparseable = Vec::new();
393    let mut next_probe = 0_u64;
394    let mut next_decision = 0_u32;
395    for relative in &files.sources {
396        let path = root.join(relative);
397        let Ok(source) = std::fs::read_to_string(&path) else {
398            let reason = "file could not be read as UTF-8";
399            manifest
400                .limitations
401                .push(unparseable_limitation(relative, reason));
402            unparseable.push((relative.clone(), reason.to_owned()));
403            continue;
404        };
405        match crate::go_instrumenter::build_go_obligations_for(
406            relative,
407            &source,
408            &mut next_probe,
409            &mut next_decision,
410            crate::go_instrumenter::RUNTIME_ALIAS,
411            has_generics(root, relative, &source),
412        ) {
413            Ok(obligations) => {
414                manifest.decisions.extend(obligations.manifest.decisions);
415                manifest.points.extend(obligations.manifest.points);
416                manifest.branches.extend(obligations.manifest.branches);
417                // What the file could not be measured for travels with what it
418                // could. Dropping these left the manifest silently claiming a
419                // completeness it had not established.
420                manifest
421                    .limitations
422                    .extend(obligations.manifest.limitations);
423                probes.extend(obligations.probes);
424                decision_widths.extend(obligations.decision_widths);
425                instrumented.push((
426                    relative.clone(),
427                    crate::go_instrumenter::rewrite(&source, &obligations.edits),
428                ));
429            }
430            Err(error) => {
431                manifest
432                    .limitations
433                    .push(unparseable_limitation(relative, &error.to_string()));
434                unparseable.push((relative.clone(), error.to_string()));
435            }
436        }
437    }
438    let scope = match roots {
439        Some(roots) => roots
440            .scope(&files.sources, &files.excluded)
441            .map_err(|error| error.to_string())?,
442        None => {
443            let unread = |file: &String| unparseable.iter().any(|(path, _)| path == file);
444            crate::source_discovery::automatic_scope(
445                files
446                    .sources
447                    .iter()
448                    .filter(|file| !unread(file))
449                    .map(|file| (file.clone(), "Go application source".to_owned())),
450                files
451                    .sources
452                    .iter()
453                    .filter(|file| unread(file))
454                    .map(|file| (file.clone(), "could not be parsed".to_owned()))
455                    .chain(
456                        files
457                            .tests
458                            .iter()
459                            .map(|file| (file.clone(), "test file".to_owned())),
460                    )
461                    .chain(
462                        // A directory left out whole is not a file to list.
463                        files
464                            .excluded
465                            .iter()
466                            .filter(|(file, _)| file.ends_with(".go"))
467                            .map(|(file, reason)| (file.clone(), (*reason).to_owned())),
468                    ),
469            )
470        }
471    };
472    manifest.scope = Some(serde_json::to_value(scope).map_err(|error| error.to_string())?);
473    Ok(PreparedGoProject {
474        root: root.to_owned(),
475        files,
476        manifest,
477        probes,
478        instrumented,
479        decision_widths,
480        unparseable,
481    })
482}
483
484/// Integrity inputs: sources and tests are hashed separately, dependency and
485/// configuration files identify the environment, and the test command
486/// identifies execution.
487///
488/// The ambient environment is deliberately absent, for the same reason it is
489/// absent everywhere else: it is not a property of the project.
490pub fn go_integrity_inputs(files: &GoFiles, command: &[String]) -> ExplicitIntegrityInputs {
491    ExplicitIntegrityInputs {
492        source_files: files.sources.iter().map(PathBuf::from).collect(),
493        test_files: files.tests.iter().map(PathBuf::from).collect(),
494        dependency_files: files.dependency_files.clone(),
495        configuration_files: files.configuration_files.clone(),
496        execution_configuration: command.join("\0").into_bytes(),
497    }
498}
499
500/// Whether a file's Go has generics (1.18): its module's `go` directive --
501/// 1.16 when it has none, as the toolchain reads it -- unless a
502/// `//go:build go1.N` line above its package clause sets an older one.
503fn has_generics(root: &Path, relative: &str, source: &str) -> bool {
504    let minor = |text: &str| -> Option<u32> {
505        text.strip_prefix("1.")?
506            .split(|c: char| !c.is_ascii_digit())
507            .next()?
508            .parse()
509            .ok()
510    };
511    let mut directory = root.join(relative);
512    let module = loop {
513        if !directory.pop() || !directory.starts_with(root) {
514            break None;
515        }
516        if let Ok(text) = std::fs::read_to_string(directory.join("go.mod")) {
517            break Some(text);
518        }
519    };
520    let declared = module
521        .as_deref()
522        .and_then(|text| {
523            text.lines()
524                .map(str::trim)
525                .find_map(|line| line.strip_prefix("go ").map(str::trim))
526                .and_then(minor)
527        })
528        .unwrap_or(16);
529    let header = source.split("\npackage ").next().unwrap_or("");
530    let constrained = header
531        .lines()
532        .filter_map(|line| line.trim().strip_prefix("//go:build"))
533        .flat_map(|constraint| {
534            constraint
535                .split(|c: char| !(c.is_ascii_alphanumeric() || c == '.' || c == '!'))
536                .filter_map(|term| term.strip_prefix("go").and_then(minor))
537                .collect::<Vec<_>>()
538        })
539        .min();
540    constrained.map_or(declared, |file| file.min(declared)) >= 18
541}
542
543/// The module path declared in `go.mod`, which package identities are relative
544/// to.
545pub fn module_path(root: &Path) -> Option<String> {
546    let text = std::fs::read_to_string(root.join("go.mod")).ok()?;
547    text.lines()
548        .map(str::trim)
549        .find_map(|line| line.strip_prefix("module "))
550        .map(|path| path.trim().to_owned())
551}
552
553/// Package directories that hold at least one test file, which is the unit
554/// `go test` actually runs.
555pub fn test_packages(files: &GoFiles) -> Vec<String> {
556    files
557        .tests
558        .iter()
559        .map(|test| match test.rsplit_once('/') {
560            Some((directory, _)) => directory.to_owned(),
561            None => ".".to_owned(),
562        })
563        .collect::<BTreeSet<_>>()
564        .into_iter()
565        .collect()
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use std::fs;
572
573    #[test]
574    fn only_the_constraint_nothing_ever_defines_is_read_alone() {
575        // `ignore` is not a GOOS, not a GOARCH, and nothing sets it, which is
576        // why it is the convention for Go kept beside Go without being
577        // compiled. Every other constraint needs a GOOS, a GOARCH and a tag
578        // set to answer, and guessing at those would drop files a run does
579        // measure.
580        assert!(build_ignored("//go:build ignore\n\npackage p\n"));
581        assert!(build_ignored("// +build ignore\n\npackage p\n"));
582        assert!(build_ignored(
583            "// Copyright.\n\n//go:build ignore\n\npackage p\n"
584        ));
585        // Constraints combine: a required term is still required, and an
586        // alternative to it is built wherever the alternative holds.
587        assert!(build_ignored("//go:build ignore && linux\n\npackage p\n"));
588        assert!(build_ignored("// +build linux,ignore\n\npackage p\n"));
589        assert!(!build_ignored("//go:build ignore || linux\n\npackage p\n"));
590        assert!(!build_ignored("// +build ignore linux\n\npackage p\n"));
591        assert!(!build_ignored("//go:build !ignore\n\npackage p\n"));
592
593        // Everything else is a question this cannot answer, so it does not.
594        assert!(!build_ignored("//go:build linux\n\npackage p\n"));
595        assert!(!build_ignored("//go:build !windows\n\npackage p\n"));
596        assert!(!build_ignored("package p\n"));
597        // A tag that merely starts with the word is a different tag.
598        assert!(!build_ignored("//go:build ignored\n\npackage p\n"));
599        assert!(!build_ignored("//go:build ignore_me\n\npackage p\n"));
600        // And a constraint has to be above the package clause to be one. The
601        // toolchain ignores the text below it, so reading it there would drop
602        // a file the build takes.
603        assert!(!build_ignored(
604            "package p\n\n//go:build ignore\n\nfunc f() {}\n"
605        ));
606        assert!(!build_ignored(
607            "package p\n\nvar doc = \"//go:build ignore\"\n"
608        ));
609    }
610
611    fn fixture(label: &str) -> PathBuf {
612        let root = std::env::temp_dir().join(format!(
613            "supercov-go-project-{label}-{}-{}",
614            std::process::id(),
615            std::time::SystemTime::now()
616                .duration_since(std::time::UNIX_EPOCH)
617                .unwrap()
618                .as_nanos()
619        ));
620        fs::create_dir_all(&root).unwrap();
621        root
622    }
623
624    fn write(root: &Path, relative: &str, contents: &str) {
625        let path = root.join(relative);
626        fs::create_dir_all(path.parent().unwrap()).unwrap();
627        fs::write(path, contents).unwrap();
628    }
629
630    fn explicit_roots(root: &Path, roots: &[&str]) -> crate::source_discovery::ExplicitSourceRoots {
631        let roots = roots
632            .iter()
633            .map(|root| (*root).to_owned())
634            .collect::<Vec<_>>();
635        crate::source_discovery::ExplicitSourceRoots::resolve(root, &roots).unwrap()
636    }
637
638    #[test]
639    fn explicit_roots_narrow_go_to_the_code_they_name() {
640        // `extra`, not `third_party`: Go already leaves third_party, vendor and
641        // testdata out by default, and a test passing for that reason would
642        // prove nothing about roots. Go had no scope to record at all, so honouring the variable there has
643        // to leave a trace of what it did, or it is as invisible as ignoring it.
644        let root = fixture("explicit-roots");
645        write(&root, "go.mod", "module example.com/app\n");
646        write(
647            &root,
648            "app.go",
649            "package main\n\nfunc f(a int) int {\n\treturn a\n}\n",
650        );
651        write(
652            &root,
653            "extra/dep.go",
654            "package extra\n\nfunc G() int {\n\treturn 1\n}\n",
655        );
656
657        let project = prepare_go_project(&root, Some(&explicit_roots(&root, &["app.go"]))).unwrap();
658        assert_eq!(project.files.sources, ["app.go"], "{:?}", project.files);
659        assert!(
660            project
661                .files
662                .excluded
663                .contains(&("extra/dep.go".into(), "outside explicit source roots")),
664            "{:?}",
665            project.files.excluded
666        );
667        let scope: crate::source_discovery::SourceScope = serde_json::from_value(
668            project
669                .manifest
670                .scope
671                .clone()
672                .expect("explicit roots leave a scope"),
673        )
674        .unwrap();
675        assert_eq!(
676            scope.mode,
677            crate::source_discovery::SourceScopeMode::Explicit
678        );
679        assert_eq!(scope.roots, ["app.go"]);
680
681        fs::remove_dir_all(root).ok();
682    }
683
684    #[test]
685    fn a_run_lists_what_it_measures_and_what_it_left_out() {
686        // `runs <id> scope` answered that a Go run held no inventory. And two
687        // kinds of file were measured that no command of the project's runs:
688        // chi's `_examples`, which the toolchain walks past, and the
689        // `examples/` programs of gorilla/websocket, a quarter of its lines.
690        let root = fixture("automatic-scope");
691        write(&root, "go.mod", "module example.com/app\n");
692        let function = "\n\nfunc F(a int) int {\n\treturn a\n}\n";
693        write(&root, "app.go", &format!("package app{function}"));
694        write(&root, "app_test.go", "package app\n");
695        write(
696            &root,
697            "_examples/hello/main.go",
698            &format!("package main{function}"),
699        );
700        write(&root, "_draft.go", &format!("package app{function}"));
701        write(
702            &root,
703            "examples/echo/main.go",
704            &format!("package main{function}"),
705        );
706        write(
707            &root,
708            "internal/examples/kept.go",
709            &format!("package examples{function}"),
710        );
711        write(&root, "doc/gen.go", &format!("package doc{function}"));
712        write(
713            &root,
714            "vendor/other/lib.go",
715            &format!("package lib{function}"),
716        );
717
718        let project = prepare_go_project(&root, None).unwrap();
719        assert_eq!(
720            project.files.sources,
721            ["app.go", "doc/gen.go", "internal/examples/kept.go"]
722        );
723        let scope: crate::source_discovery::SourceScope =
724            serde_json::from_value(project.manifest.scope.clone().expect("a scope")).unwrap();
725        assert_eq!(
726            scope.mode,
727            crate::source_discovery::SourceScopeMode::Automatic
728        );
729        let listed = scope
730            .entries
731            .iter()
732            .map(|entry| {
733                (
734                    entry.file.as_str(),
735                    entry.status == crate::source_discovery::SourceScopeStatus::Included,
736                    entry.reason.as_str(),
737                )
738            })
739            .collect::<Vec<_>>();
740        assert_eq!(
741            listed,
742            [
743                ("_draft.go", false, "ignored by the Go toolchain"),
744                (
745                    "_examples/hello/main.go",
746                    false,
747                    "ignored by the Go toolchain"
748                ),
749                ("app.go", true, "Go application source"),
750                ("app_test.go", false, "test file"),
751                ("doc/gen.go", true, "Go application source"),
752                ("examples/echo/main.go", false, "example"),
753                ("internal/examples/kept.go", true, "Go application source"),
754            ]
755        );
756
757        // A root someone named is what they say it is.
758        let named = prepare_go_project(&root, Some(&explicit_roots(&root, &["examples"]))).unwrap();
759        assert_eq!(named.files.sources, ["examples/echo/main.go"]);
760        fs::remove_dir_all(root).ok();
761    }
762
763    #[test]
764    fn a_test_file_is_the_one_the_toolchain_says_it_is() {
765        // Go settles this: `_test.go` and nothing else. No directory-name
766        // heuristic to disagree with the compiler about.
767        for test in ["main_test.go", "pkg/api/handler_test.go", "a/b/z_test.go"] {
768            assert!(is_test_file(test), "{test}");
769        }
770        for source in ["main.go", "pkg/api/handler.go", "testing.go", "pkg/test.go"] {
771            assert!(!is_test_file(source), "{source}");
772        }
773    }
774
775    #[test]
776    fn vendored_and_toolchain_directories_are_not_this_project_s_source() {
777        // `vendor` is someone else's code and `testdata` is ignored by the
778        // compiler itself; measuring either puts obligations on code these
779        // tests were never meant to exercise.
780        let root = fixture("scope");
781        write(&root, "go.mod", "module example.com/app\n\ngo 1.22\n");
782        write(&root, "go.sum", "");
783        write(&root, ".go-version", "1.22.0\n");
784        write(&root, "main.go", "package main\n\nfunc main() {}\n");
785        write(
786            &root,
787            "pkg/api/handler.go",
788            "package api\n\nfunc Handle() int {\n\treturn 1\n}\n",
789        );
790        write(
791            &root,
792            "pkg/api/handler_test.go",
793            "package api\n\nimport \"testing\"\n\nfunc TestHandle(t *testing.T) {}\n",
794        );
795        write(
796            &root,
797            "vendor/other/lib.go",
798            "package other\n\nfunc X() {}\n",
799        );
800        write(&root, "testdata/golden.go", "package testdata\n");
801        write(&root, "bin/tool.go", "package main\n");
802
803        let files = discover_go_files(&root).unwrap();
804        assert_eq!(files.sources, ["main.go", "pkg/api/handler.go"]);
805        assert_eq!(files.tests, ["pkg/api/handler_test.go"]);
806        assert_eq!(
807            files.dependency_files,
808            [PathBuf::from("go.mod"), PathBuf::from("go.sum")]
809        );
810        assert_eq!(files.configuration_files, [PathBuf::from(".go-version")]);
811        assert_eq!(module_path(&root).as_deref(), Some("example.com/app"));
812        assert_eq!(test_packages(&files), ["pkg/api"]);
813        fs::remove_dir_all(root).unwrap();
814    }
815
816    #[test]
817    fn a_file_that_does_not_parse_is_reported_rather_than_skipped() {
818        // A hole in the denominator that nobody is told about is a wrong
819        // number, so the project still prepares and names what it could not
820        // read.
821        let root = fixture("unparseable");
822        write(&root, "go.mod", "module example.com/app\n");
823        write(
824            &root,
825            "good.go",
826            "package main\n\nfunc f(a int) bool {\n\tif a > 1 && a < 9 {\n\t\treturn true\n\t}\n\treturn false\n}\n",
827        );
828        write(&root, "broken.go", "package main\n\nfunc f( {\n");
829
830        let project = prepare_go_project(&root, None).unwrap();
831        assert_eq!(project.unparseable.len(), 1);
832        assert_eq!(project.unparseable[0].0, "broken.go");
833        // And the hole it leaves is declared, not merely printed: a
834        // diagnostic scrolls past, a limitation reaches the stored run.
835        let declared = project
836            .manifest
837            .limitations
838            .iter()
839            .filter(|limitation| limitation["kind"] == "file-does-not-parse")
840            .collect::<Vec<_>>();
841        assert_eq!(declared.len(), 1, "{declared:?}");
842        assert!(
843            declared[0]["file"].as_str().unwrap().ends_with("broken.go"),
844            "{declared:?}"
845        );
846        assert!(
847            declared[0]["id"].as_str().is_some_and(|id| !id.is_empty()),
848            "the declaration needs an id to reference: {declared:?}"
849        );
850        // The readable file still contributed its obligations.
851        assert!(!project.manifest.points.is_empty());
852        assert_eq!(project.manifest.decisions.len(), 1);
853        assert!(!project.probes.is_empty());
854        fs::remove_dir_all(root).unwrap();
855    }
856
857    #[test]
858    fn an_empty_project_is_refused_rather_than_measured_as_complete() {
859        // Zero of zero satisfies every floor, so a project with nothing to
860        // measure has to say so instead of reporting success.
861        let root = fixture("empty");
862        write(&root, "go.mod", "module example.com/app\n");
863        assert!(prepare_go_project(&root, None).is_err());
864        fs::remove_dir_all(root).unwrap();
865    }
866
867    #[test]
868    fn the_ambient_environment_is_not_part_of_run_identity() {
869        // Identity is what the project is, not which shell it was run from.
870        let files = GoFiles::default();
871        let inputs = go_integrity_inputs(
872            &files,
873            &["go".to_owned(), "test".to_owned(), "./...".to_owned()],
874        );
875        assert_eq!(inputs.execution_configuration, b"go\0test\0./...");
876    }
877
878    #[test]
879    fn a_nested_module_belongs_to_itself() {
880        // `go test ./...` walks straight past a directory with a go.mod of its
881        // own, so measuring it would put obligations in the denominator that
882        // no test here can reach -- and a probe file written there would
883        // import a runtime the nested module cannot resolve, which stops the
884        // build that worked before Supercov was asked to measure it.
885        let root = fixture("nested-module");
886        fs::write(root.join("go.mod"), "module example.com/root\n").unwrap();
887        fs::write(root.join("root.go"), "package root\n\nfunc A() {}\n").unwrap();
888        fs::create_dir_all(root.join("sub")).unwrap();
889        fs::write(root.join("sub/go.mod"), "module example.com/sub\n").unwrap();
890        fs::write(root.join("sub/sub.go"), "package sub\n\nfunc B() {}\n").unwrap();
891        // A plain subdirectory of this module is still measured.
892        fs::create_dir_all(root.join("internal")).unwrap();
893        fs::write(
894            root.join("internal/helper.go"),
895            "package internal\n\nfunc C() {}\n",
896        )
897        .unwrap();
898
899        let files = discover_go_files(&root).expect("discovery");
900        assert_eq!(files.sources, ["internal/helper.go", "root.go"]);
901        assert!(
902            files
903                .excluded
904                .iter()
905                .any(|(path, reason)| path == "sub" && *reason == "a module of its own"),
906            "{:?}",
907            files.excluded
908        );
909        fs::remove_dir_all(root).unwrap();
910    }
911
912    #[test]
913    fn a_workspace_names_the_modules_it_uses() {
914        // go.work writes `use` either inline or as a block, with comments and
915        // quoting allowed. Its members are modules Supercov must measure --
916        // the opposite of a nested module it must leave alone -- so reading
917        // the file wrong means either measuring nothing or breaking a build.
918        let root = fixture("go-work");
919        fs::write(
920            root.join("go.work"),
921            "go 1.22\n\n// the services\nuse (\n\t./core\n\t\"./app\"  // quoted\n\t./tools/gen\n)\n",
922        )
923        .unwrap();
924        let modules = workspace_modules(&root);
925        assert_eq!(
926            modules.iter().map(String::as_str).collect::<Vec<_>>(),
927            ["app", "core", "tools/gen"]
928        );
929
930        // The inline form says the same thing.
931        fs::write(root.join("go.work"), "go 1.22\n\nuse ./only\n").unwrap();
932        assert_eq!(
933            workspace_modules(&root)
934                .iter()
935                .map(String::as_str)
936                .collect::<Vec<_>>(),
937            ["only"]
938        );
939
940        // And a repository with no workspace has none, which is how a single
941        // module at the root is told apart from a workspace member.
942        fs::remove_file(root.join("go.work")).unwrap();
943        assert!(workspace_modules(&root).is_empty());
944        fs::remove_dir_all(root).unwrap();
945    }
946
947    #[test]
948    fn a_workspace_member_is_measured_where_a_nested_module_is_not() {
949        // Both are directories with a go.mod under the root. One is part of
950        // what the command runs and one is not, and only go.work says which.
951        let root = fixture("go-work-members");
952        fs::write(root.join("go.work"), "go 1.22\n\nuse (\n\t./core\n)\n").unwrap();
953        for module in ["core", "vendored"] {
954            fs::create_dir_all(root.join(module)).unwrap();
955            fs::write(
956                root.join(module).join("go.mod"),
957                format!("module example.com/{module}\n"),
958            )
959            .unwrap();
960            fs::write(
961                root.join(module).join("code.go"),
962                format!("package {module}\n\nfunc A() {{}}\n"),
963            )
964            .unwrap();
965        }
966        let files = discover_go_files(&root).expect("discovery");
967        assert_eq!(files.sources, ["core/code.go"]);
968        assert!(
969            files
970                .excluded
971                .iter()
972                .any(|(path, reason)| path == "vendored" && *reason == "a module of its own"),
973            "{:?}",
974            files.excluded
975        );
976        fs::remove_dir_all(root).unwrap();
977    }
978}