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, build_go_obligations};
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        if is_test_file(&relative) {
208            files.tests.push(relative);
209        } else {
210            files.sources.push(relative);
211        }
212    }
213    Ok(())
214}
215
216const IGNORED_BY_THE_TOOLCHAIN: &str = "ignored by the Go toolchain";
217
218/// Every `.go` file under a directory the toolchain ignores.
219fn ignored_by_the_toolchain(
220    root: &Path,
221    directory: &Path,
222    files: &mut GoFiles,
223) -> Result<(), String> {
224    let entries = std::fs::read_dir(directory)
225        .map_err(|error| format!("could not read {}: {error}", directory.display()))?;
226    for entry in entries {
227        let entry =
228            entry.map_err(|error| format!("could not read {}: {error}", directory.display()))?;
229        let path = entry.path();
230        let file_type = entry
231            .file_type()
232            .map_err(|error| format!("could not inspect {}: {error}", path.display()))?;
233        if file_type.is_dir() {
234            ignored_by_the_toolchain(root, &path, files)?;
235        } else if file_type.is_file()
236            && path.extension().is_some_and(|extension| extension == "go")
237            && let Ok(relative) = path.strip_prefix(root)
238        {
239            files.excluded.push((
240                relative.to_string_lossy().replace('\\', "/"),
241                IGNORED_BY_THE_TOOLCHAIN,
242            ));
243        }
244    }
245    Ok(())
246}
247
248/// An example program at the top of the module: gorilla/websocket keeps eight
249/// under `examples/`, a quarter of its lines, and nats.go eighty-five. Each is
250/// a `package main` no test runs. Only the directory's name is read, and only
251/// `examples`: cobra's `doc` and nats.go's `bench` are packages of the product
252/// with tests of their own.
253fn example_program(file: &str) -> bool {
254    file.split_once('/').is_some_and(|(first, _)| {
255        matches!(first.to_ascii_lowercase().as_str(), "examples" | "example")
256    })
257}
258
259pub fn discover_go_files(root: &Path) -> Result<GoFiles, String> {
260    let mut files = GoFiles::default();
261    walk(root, root, &mut files)?;
262    files.sources.sort();
263    files.tests.sort();
264    files.dependency_files.sort();
265    files.configuration_files.sort();
266    files.excluded.sort();
267    Ok(files)
268}
269
270const LANGUAGE: &str = "go";
271
272/// A file the parser could not read is a hole in the denominator, and a hole
273/// nobody can see is worse than one they can. A diagnostic line scrolls past;
274/// this puts the file in the manifest, so it reaches the declaration's
275/// structural limitations and `supercov runs latest` can still name it long
276/// after the build log is gone.
277pub(crate) fn unparseable_limitation(file: &str, reason: &str) -> serde_json::Value {
278    serde_json::json!({
279        "id": crate::go_instrumenter::stable_obligation_id(LANGUAGE, file, "unparseable", 0, 0),
280        "kind": "file-does-not-parse",
281        "file": file,
282        // The surface is the whole file: there is no construct to quote,
283        // because nothing in it parsed.
284        "source": file,
285        "line": 1,
286        "column": 1,
287        "reason": format!(
288            "{reason}; the file carries no obligations and nothing in it counts towards this run"
289        ),
290    })
291}
292
293pub fn prepare_go_project(
294    root: &Path,
295    roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
296) -> Result<PreparedGoProject, String> {
297    let mut files = discover_go_files(root)?;
298    match roots {
299        Some(roots) => {
300            roots.narrow(&mut files.sources, &mut files.excluded, String::as_str);
301            roots.refuse_if_empty(files.sources.len(), "Go")?;
302        }
303        // A root someone named is what they say it is.
304        None => {
305            let GoFiles {
306                sources, excluded, ..
307            } = &mut files;
308            sources.retain(|file| {
309                let example = example_program(file);
310                if example {
311                    excluded.push((file.clone(), "example"));
312                }
313                !example
314            });
315            excluded.sort();
316        }
317    }
318    if files.sources.is_empty() && files.tests.is_empty() {
319        return Err(
320            "no Go source files were found under the project root; Supercov measures .go files outside vendor, testdata and tooling directories"
321                .into(),
322        );
323    }
324    let mut manifest = CoverageManifest {
325        decisions: Vec::new(),
326        points: Vec::new(),
327        branches: Vec::new(),
328        limitations: Vec::new(),
329        unmeasured: Vec::new(),
330        scope: None,
331        assertion_sites: Vec::new(),
332    };
333    let mut probes = std::collections::BTreeMap::new();
334    let mut instrumented = Vec::new();
335    let mut decision_widths = Vec::new();
336    let mut unparseable = Vec::new();
337    let mut next_probe = 0_u64;
338    let mut next_decision = 0_u32;
339    for relative in &files.sources {
340        let path = root.join(relative);
341        let Ok(source) = std::fs::read_to_string(&path) else {
342            let reason = "file could not be read as UTF-8";
343            manifest
344                .limitations
345                .push(unparseable_limitation(relative, reason));
346            unparseable.push((relative.clone(), reason.to_owned()));
347            continue;
348        };
349        match build_go_obligations(relative, &source, &mut next_probe, &mut next_decision) {
350            Ok(obligations) => {
351                manifest.decisions.extend(obligations.manifest.decisions);
352                manifest.points.extend(obligations.manifest.points);
353                manifest.branches.extend(obligations.manifest.branches);
354                // What the file could not be measured for travels with what it
355                // could. Dropping these left the manifest silently claiming a
356                // completeness it had not established.
357                manifest
358                    .limitations
359                    .extend(obligations.manifest.limitations);
360                probes.extend(obligations.probes);
361                decision_widths.extend(obligations.decision_widths);
362                instrumented.push((
363                    relative.clone(),
364                    crate::go_instrumenter::rewrite(&source, &obligations.edits),
365                ));
366            }
367            Err(error) => {
368                manifest
369                    .limitations
370                    .push(unparseable_limitation(relative, &error.to_string()));
371                unparseable.push((relative.clone(), error.to_string()));
372            }
373        }
374    }
375    let scope = match roots {
376        Some(roots) => roots
377            .scope(&files.sources, &files.excluded)
378            .map_err(|error| error.to_string())?,
379        None => {
380            let unread = |file: &String| unparseable.iter().any(|(path, _)| path == file);
381            crate::source_discovery::automatic_scope(
382                files
383                    .sources
384                    .iter()
385                    .filter(|file| !unread(file))
386                    .map(|file| (file.clone(), "Go application source".to_owned())),
387                files
388                    .sources
389                    .iter()
390                    .filter(|file| unread(file))
391                    .map(|file| (file.clone(), "could not be parsed".to_owned()))
392                    .chain(
393                        files
394                            .tests
395                            .iter()
396                            .map(|file| (file.clone(), "test file".to_owned())),
397                    )
398                    .chain(
399                        // A directory left out whole is not a file to list.
400                        files
401                            .excluded
402                            .iter()
403                            .filter(|(file, _)| file.ends_with(".go"))
404                            .map(|(file, reason)| (file.clone(), (*reason).to_owned())),
405                    ),
406            )
407        }
408    };
409    manifest.scope = Some(serde_json::to_value(scope).map_err(|error| error.to_string())?);
410    Ok(PreparedGoProject {
411        root: root.to_owned(),
412        files,
413        manifest,
414        probes,
415        instrumented,
416        decision_widths,
417        unparseable,
418    })
419}
420
421/// Integrity inputs: sources and tests are hashed separately, dependency and
422/// configuration files identify the environment, and the test command
423/// identifies execution.
424///
425/// The ambient environment is deliberately absent, for the same reason it is
426/// absent everywhere else: it is not a property of the project.
427pub fn go_integrity_inputs(files: &GoFiles, command: &[String]) -> ExplicitIntegrityInputs {
428    ExplicitIntegrityInputs {
429        source_files: files.sources.iter().map(PathBuf::from).collect(),
430        test_files: files.tests.iter().map(PathBuf::from).collect(),
431        dependency_files: files.dependency_files.clone(),
432        configuration_files: files.configuration_files.clone(),
433        execution_configuration: command.join("\0").into_bytes(),
434    }
435}
436
437/// The module path declared in `go.mod`, which package identities are relative
438/// to.
439pub fn module_path(root: &Path) -> Option<String> {
440    let text = std::fs::read_to_string(root.join("go.mod")).ok()?;
441    text.lines()
442        .map(str::trim)
443        .find_map(|line| line.strip_prefix("module "))
444        .map(|path| path.trim().to_owned())
445}
446
447/// Package directories that hold at least one test file, which is the unit
448/// `go test` actually runs.
449pub fn test_packages(files: &GoFiles) -> Vec<String> {
450    files
451        .tests
452        .iter()
453        .map(|test| match test.rsplit_once('/') {
454            Some((directory, _)) => directory.to_owned(),
455            None => ".".to_owned(),
456        })
457        .collect::<BTreeSet<_>>()
458        .into_iter()
459        .collect()
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use std::fs;
466
467    fn fixture(label: &str) -> PathBuf {
468        let root = std::env::temp_dir().join(format!(
469            "supercov-go-project-{label}-{}-{}",
470            std::process::id(),
471            std::time::SystemTime::now()
472                .duration_since(std::time::UNIX_EPOCH)
473                .unwrap()
474                .as_nanos()
475        ));
476        fs::create_dir_all(&root).unwrap();
477        root
478    }
479
480    fn write(root: &Path, relative: &str, contents: &str) {
481        let path = root.join(relative);
482        fs::create_dir_all(path.parent().unwrap()).unwrap();
483        fs::write(path, contents).unwrap();
484    }
485
486    fn explicit_roots(root: &Path, roots: &[&str]) -> crate::source_discovery::ExplicitSourceRoots {
487        let roots = roots
488            .iter()
489            .map(|root| (*root).to_owned())
490            .collect::<Vec<_>>();
491        crate::source_discovery::ExplicitSourceRoots::resolve(root, &roots).unwrap()
492    }
493
494    #[test]
495    fn explicit_roots_narrow_go_to_the_code_they_name() {
496        // `extra`, not `third_party`: Go already leaves third_party, vendor and
497        // testdata out by default, and a test passing for that reason would
498        // prove nothing about roots. Go had no scope to record at all, so honouring the variable there has
499        // to leave a trace of what it did, or it is as invisible as ignoring it.
500        let root = fixture("explicit-roots");
501        write(&root, "go.mod", "module example.com/app\n");
502        write(
503            &root,
504            "app.go",
505            "package main\n\nfunc f(a int) int {\n\treturn a\n}\n",
506        );
507        write(
508            &root,
509            "extra/dep.go",
510            "package extra\n\nfunc G() int {\n\treturn 1\n}\n",
511        );
512
513        let project = prepare_go_project(&root, Some(&explicit_roots(&root, &["app.go"]))).unwrap();
514        assert_eq!(project.files.sources, ["app.go"], "{:?}", project.files);
515        assert!(
516            project
517                .files
518                .excluded
519                .contains(&("extra/dep.go".into(), "outside explicit source roots")),
520            "{:?}",
521            project.files.excluded
522        );
523        let scope: crate::source_discovery::SourceScope = serde_json::from_value(
524            project
525                .manifest
526                .scope
527                .clone()
528                .expect("explicit roots leave a scope"),
529        )
530        .unwrap();
531        assert_eq!(
532            scope.mode,
533            crate::source_discovery::SourceScopeMode::Explicit
534        );
535        assert_eq!(scope.roots, ["app.go"]);
536
537        fs::remove_dir_all(root).ok();
538    }
539
540    #[test]
541    fn a_run_lists_what_it_measures_and_what_it_left_out() {
542        // `runs <id> scope` answered that a Go run held no inventory. And two
543        // kinds of file were measured that no command of the project's runs:
544        // chi's `_examples`, which the toolchain walks past, and the
545        // `examples/` programs of gorilla/websocket, a quarter of its lines.
546        let root = fixture("automatic-scope");
547        write(&root, "go.mod", "module example.com/app\n");
548        let function = "\n\nfunc F(a int) int {\n\treturn a\n}\n";
549        write(&root, "app.go", &format!("package app{function}"));
550        write(&root, "app_test.go", "package app\n");
551        write(
552            &root,
553            "_examples/hello/main.go",
554            &format!("package main{function}"),
555        );
556        write(&root, "_draft.go", &format!("package app{function}"));
557        write(
558            &root,
559            "examples/echo/main.go",
560            &format!("package main{function}"),
561        );
562        write(
563            &root,
564            "internal/examples/kept.go",
565            &format!("package examples{function}"),
566        );
567        write(&root, "doc/gen.go", &format!("package doc{function}"));
568        write(
569            &root,
570            "vendor/other/lib.go",
571            &format!("package lib{function}"),
572        );
573
574        let project = prepare_go_project(&root, None).unwrap();
575        assert_eq!(
576            project.files.sources,
577            ["app.go", "doc/gen.go", "internal/examples/kept.go"]
578        );
579        let scope: crate::source_discovery::SourceScope =
580            serde_json::from_value(project.manifest.scope.clone().expect("a scope")).unwrap();
581        assert_eq!(
582            scope.mode,
583            crate::source_discovery::SourceScopeMode::Automatic
584        );
585        let listed = scope
586            .entries
587            .iter()
588            .map(|entry| {
589                (
590                    entry.file.as_str(),
591                    entry.status == crate::source_discovery::SourceScopeStatus::Included,
592                    entry.reason.as_str(),
593                )
594            })
595            .collect::<Vec<_>>();
596        assert_eq!(
597            listed,
598            [
599                ("_draft.go", false, "ignored by the Go toolchain"),
600                (
601                    "_examples/hello/main.go",
602                    false,
603                    "ignored by the Go toolchain"
604                ),
605                ("app.go", true, "Go application source"),
606                ("app_test.go", false, "test file"),
607                ("doc/gen.go", true, "Go application source"),
608                ("examples/echo/main.go", false, "example"),
609                ("internal/examples/kept.go", true, "Go application source"),
610            ]
611        );
612
613        // A root someone named is what they say it is.
614        let named = prepare_go_project(&root, Some(&explicit_roots(&root, &["examples"]))).unwrap();
615        assert_eq!(named.files.sources, ["examples/echo/main.go"]);
616        fs::remove_dir_all(root).ok();
617    }
618
619    #[test]
620    fn a_test_file_is_the_one_the_toolchain_says_it_is() {
621        // Go settles this: `_test.go` and nothing else. No directory-name
622        // heuristic to disagree with the compiler about.
623        for test in ["main_test.go", "pkg/api/handler_test.go", "a/b/z_test.go"] {
624            assert!(is_test_file(test), "{test}");
625        }
626        for source in ["main.go", "pkg/api/handler.go", "testing.go", "pkg/test.go"] {
627            assert!(!is_test_file(source), "{source}");
628        }
629    }
630
631    #[test]
632    fn vendored_and_toolchain_directories_are_not_this_project_s_source() {
633        // `vendor` is someone else's code and `testdata` is ignored by the
634        // compiler itself; measuring either puts obligations on code these
635        // tests were never meant to exercise.
636        let root = fixture("scope");
637        write(&root, "go.mod", "module example.com/app\n\ngo 1.22\n");
638        write(&root, "go.sum", "");
639        write(&root, ".go-version", "1.22.0\n");
640        write(&root, "main.go", "package main\n\nfunc main() {}\n");
641        write(
642            &root,
643            "pkg/api/handler.go",
644            "package api\n\nfunc Handle() int {\n\treturn 1\n}\n",
645        );
646        write(
647            &root,
648            "pkg/api/handler_test.go",
649            "package api\n\nimport \"testing\"\n\nfunc TestHandle(t *testing.T) {}\n",
650        );
651        write(
652            &root,
653            "vendor/other/lib.go",
654            "package other\n\nfunc X() {}\n",
655        );
656        write(&root, "testdata/golden.go", "package testdata\n");
657        write(&root, "bin/tool.go", "package main\n");
658
659        let files = discover_go_files(&root).unwrap();
660        assert_eq!(files.sources, ["main.go", "pkg/api/handler.go"]);
661        assert_eq!(files.tests, ["pkg/api/handler_test.go"]);
662        assert_eq!(
663            files.dependency_files,
664            [PathBuf::from("go.mod"), PathBuf::from("go.sum")]
665        );
666        assert_eq!(files.configuration_files, [PathBuf::from(".go-version")]);
667        assert_eq!(module_path(&root).as_deref(), Some("example.com/app"));
668        assert_eq!(test_packages(&files), ["pkg/api"]);
669        fs::remove_dir_all(root).unwrap();
670    }
671
672    #[test]
673    fn a_file_that_does_not_parse_is_reported_rather_than_skipped() {
674        // A hole in the denominator that nobody is told about is a wrong
675        // number, so the project still prepares and names what it could not
676        // read.
677        let root = fixture("unparseable");
678        write(&root, "go.mod", "module example.com/app\n");
679        write(
680            &root,
681            "good.go",
682            "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",
683        );
684        write(&root, "broken.go", "package main\n\nfunc f( {\n");
685
686        let project = prepare_go_project(&root, None).unwrap();
687        assert_eq!(project.unparseable.len(), 1);
688        assert_eq!(project.unparseable[0].0, "broken.go");
689        // And the hole it leaves is declared, not merely printed: a
690        // diagnostic scrolls past, a limitation reaches the stored run.
691        let declared = project
692            .manifest
693            .limitations
694            .iter()
695            .filter(|limitation| limitation["kind"] == "file-does-not-parse")
696            .collect::<Vec<_>>();
697        assert_eq!(declared.len(), 1, "{declared:?}");
698        assert!(
699            declared[0]["file"].as_str().unwrap().ends_with("broken.go"),
700            "{declared:?}"
701        );
702        assert!(
703            declared[0]["id"].as_str().is_some_and(|id| !id.is_empty()),
704            "the declaration needs an id to reference: {declared:?}"
705        );
706        // The readable file still contributed its obligations.
707        assert!(!project.manifest.points.is_empty());
708        assert_eq!(project.manifest.decisions.len(), 1);
709        assert!(!project.probes.is_empty());
710        fs::remove_dir_all(root).unwrap();
711    }
712
713    #[test]
714    fn an_empty_project_is_refused_rather_than_measured_as_complete() {
715        // Zero of zero satisfies every floor, so a project with nothing to
716        // measure has to say so instead of reporting success.
717        let root = fixture("empty");
718        write(&root, "go.mod", "module example.com/app\n");
719        assert!(prepare_go_project(&root, None).is_err());
720        fs::remove_dir_all(root).unwrap();
721    }
722
723    #[test]
724    fn the_ambient_environment_is_not_part_of_run_identity() {
725        // Identity is what the project is, not which shell it was run from.
726        let files = GoFiles::default();
727        let inputs = go_integrity_inputs(
728            &files,
729            &["go".to_owned(), "test".to_owned(), "./...".to_owned()],
730        );
731        assert_eq!(inputs.execution_configuration, b"go\0test\0./...");
732    }
733
734    #[test]
735    fn a_nested_module_belongs_to_itself() {
736        // `go test ./...` walks straight past a directory with a go.mod of its
737        // own, so measuring it would put obligations in the denominator that
738        // no test here can reach -- and a probe file written there would
739        // import a runtime the nested module cannot resolve, which stops the
740        // build that worked before Supercov was asked to measure it.
741        let root = fixture("nested-module");
742        fs::write(root.join("go.mod"), "module example.com/root\n").unwrap();
743        fs::write(root.join("root.go"), "package root\n\nfunc A() {}\n").unwrap();
744        fs::create_dir_all(root.join("sub")).unwrap();
745        fs::write(root.join("sub/go.mod"), "module example.com/sub\n").unwrap();
746        fs::write(root.join("sub/sub.go"), "package sub\n\nfunc B() {}\n").unwrap();
747        // A plain subdirectory of this module is still measured.
748        fs::create_dir_all(root.join("internal")).unwrap();
749        fs::write(
750            root.join("internal/helper.go"),
751            "package internal\n\nfunc C() {}\n",
752        )
753        .unwrap();
754
755        let files = discover_go_files(&root).expect("discovery");
756        assert_eq!(files.sources, ["internal/helper.go", "root.go"]);
757        assert!(
758            files
759                .excluded
760                .iter()
761                .any(|(path, reason)| path == "sub" && *reason == "a module of its own"),
762            "{:?}",
763            files.excluded
764        );
765        fs::remove_dir_all(root).unwrap();
766    }
767
768    #[test]
769    fn a_workspace_names_the_modules_it_uses() {
770        // go.work writes `use` either inline or as a block, with comments and
771        // quoting allowed. Its members are modules Supercov must measure --
772        // the opposite of a nested module it must leave alone -- so reading
773        // the file wrong means either measuring nothing or breaking a build.
774        let root = fixture("go-work");
775        fs::write(
776            root.join("go.work"),
777            "go 1.22\n\n// the services\nuse (\n\t./core\n\t\"./app\"  // quoted\n\t./tools/gen\n)\n",
778        )
779        .unwrap();
780        let modules = workspace_modules(&root);
781        assert_eq!(
782            modules.iter().map(String::as_str).collect::<Vec<_>>(),
783            ["app", "core", "tools/gen"]
784        );
785
786        // The inline form says the same thing.
787        fs::write(root.join("go.work"), "go 1.22\n\nuse ./only\n").unwrap();
788        assert_eq!(
789            workspace_modules(&root)
790                .iter()
791                .map(String::as_str)
792                .collect::<Vec<_>>(),
793            ["only"]
794        );
795
796        // And a repository with no workspace has none, which is how a single
797        // module at the root is told apart from a workspace member.
798        fs::remove_file(root.join("go.work")).unwrap();
799        assert!(workspace_modules(&root).is_empty());
800        fs::remove_dir_all(root).unwrap();
801    }
802
803    #[test]
804    fn a_workspace_member_is_measured_where_a_nested_module_is_not() {
805        // Both are directories with a go.mod under the root. One is part of
806        // what the command runs and one is not, and only go.work says which.
807        let root = fixture("go-work-members");
808        fs::write(root.join("go.work"), "go 1.22\n\nuse (\n\t./core\n)\n").unwrap();
809        for module in ["core", "vendored"] {
810            fs::create_dir_all(root.join(module)).unwrap();
811            fs::write(
812                root.join(module).join("go.mod"),
813                format!("module example.com/{module}\n"),
814            )
815            .unwrap();
816            fs::write(
817                root.join(module).join("code.go"),
818                format!("package {module}\n\nfunc A() {{}}\n"),
819            )
820            .unwrap();
821        }
822        let files = discover_go_files(&root).expect("discovery");
823        assert_eq!(files.sources, ["core/code.go"]);
824        assert!(
825            files
826                .excluded
827                .iter()
828                .any(|(path, reason)| path == "vendored" && *reason == "a module of its own"),
829            "{:?}",
830            files.excluded
831        );
832        fs::remove_dir_all(root).unwrap();
833    }
834}