-
Notifications
You must be signed in to change notification settings - Fork 4k
Expand file tree
/
Copy pathbuild.rs
More file actions
254 lines (239 loc) · 11.6 KB
/
Copy pathbuild.rs
File metadata and controls
254 lines (239 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
//! Build script for the `openhuman` core crate.
//!
//! Its main job is to generate the module lists for the two aggregated
//! integration test targets: `raw_coverage_all` (from `tests/raw_coverage/`)
//! and `in_process_all` (from `tests/in_process/`, the gate-free router and
//! public-API suites that used to be ~20 targets of their own). The text below
//! describes the original `raw_coverage_all` case; `in_process_all` works the
//! same way. The `tests/raw_coverage/`
//! directory holds ~76 auto-generated `*_raw_coverage_e2e.rs` coverage suites
//! that were previously ~76 separate `tests/*.rs` integration targets. Each
//! separate target statically relinks the entire (very large) `openhuman`
//! rlib, so building the test suite paid ~76 full-crate link steps. Folding
//! them into a single target ([`tests/raw_coverage_all.rs`]) reduces that to
//! one link.
//!
//! We glob the directory at build time (rather than hand-maintaining a `mod`
//! list) so that any newly added `tests/raw_coverage/*.rs` file is picked up
//! automatically and cannot be silently skipped.
//!
//! # Second job: make an unmet `required-features` skip audible
//!
//! Four `tests/*.rs` targets carry `required-features` (see `[[test]]` in
//! `Cargo.toml`), and every one of those features is default-OFF for
//! contributors. Cargo treats the two ways of selecting a target differently,
//! and only one of them is safe:
//!
//! - **Naming it** — `cargo test --test json_rpc_e2e` — is a hard error when
//! the features are unmet (`target ... requires the features: voice`,
//! exit 101). CI names its targets one at a time, so CI cannot lose one this
//! way.
//! - **Globbing** — a bare `cargo test`, or `cargo test --tests` — filters the
//! target out **silently and exits 0**. That is what a contributor runs, and
//! it reports a green suite having compiled and run none of them.
//!
//! `json_rpc_e2e` alone is >12k lines of RPC contract coverage, and
//! `raw_coverage_all` aggregates ~76 suites. A green run that skipped both
//! reads exactly like a green run that did not, which is how untested code
//! comes to look covered.
//!
//! So [`warn_about_silently_skipped_test_targets`] emits a `cargo::warning=`
//! naming what this feature configuration will skip. A build-script warning is
//! used rather than a failing test on purpose: cargo captures the output of a
//! *passing* test, so a warning printed there would itself be invisible, while
//! build-script warnings are shown unconditionally. It warns rather than fails
//! because a contributor build legitimately omits these gates — the goal is
//! that the omission is never a surprise, not that it is forbidden.
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::Path;
fn main() {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set");
let repository_root = Path::new(&manifest_dir).join("../..");
println!(
"cargo:rustc-env=OPENHUMAN_REPOSITORY_ROOT={}",
repository_root.display()
);
warn_about_silently_skipped_test_targets(Path::new(&manifest_dir));
let tests_dir = repository_root.join("tests");
// Two aggregated targets, one directory each: `raw_coverage_all` (needs the
// `voice` + `inference` gates) and `in_process_all` (gate-free router and
// public-API suites).
write_module_list(&tests_dir, "raw_coverage", "raw_coverage_mods.rs");
write_module_list(&tests_dir, "in_process", "in_process_mods.rs");
}
/// Glob `tests/<subdir>/*.rs` and write `$OUT_DIR/<out_name>`: one
/// `#[path = ...] mod <stem>;` per file, for an aggregated target to `include!`.
fn write_module_list(tests_dir: &Path, subdir: &str, out_name: &str) {
let raw_dir = tests_dir.join(subdir);
// Re-run whenever a file is added to / removed from the directory.
println!("cargo:rerun-if-changed={}", raw_dir.display());
let mut entries: Vec<(String, String)> = Vec::new();
let read_dir = match fs::read_dir(&raw_dir) {
Ok(rd) => Some(rd),
// Source-only builds (e.g. the Docker image copies `src/` but not
// `tests/`) never compile the integration targets, so there is nothing
// to aggregate — emit an empty module list rather than breaking the
// build. But if `tests/` IS present and only the subdirectory is
// missing, that's an accidental deletion: fail loudly so the suite
// can't be silently dropped.
Err(_) if !tests_dir.exists() => None,
Err(e) => panic!("failed to read {}: {e}", raw_dir.display()),
};
for entry in read_dir.into_iter().flatten().flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
// Re-run if any individual test file's contents change.
println!("cargo:rerun-if-changed={}", path.display());
// Rust `#[path]` accepts forward slashes on every platform; the
// absolute path keeps resolution independent of where the generated
// file is `include!`d from.
let abs = path.display().to_string().replace('\\', "/");
entries.push((stem, abs));
}
// Deterministic order keeps the generated file stable across builds.
entries.sort();
let mut generated = String::from("// @generated by build.rs — do not edit.\n");
for (stem, abs) in &entries {
generated.push_str(&format!("#[path = \"{abs}\"]\nmod {stem};\n"));
}
let out_dir = env::var("OUT_DIR").expect("OUT_DIR must be set");
let out_path = Path::new(&out_dir).join(out_name);
fs::write(&out_path, generated).unwrap_or_else(|e| panic!("failed to write {out_name}: {e}"));
}
/// The env var cargo sets for an enabled feature: upper-cased, `-` to `_`.
fn feature_env_var(feature: &str) -> String {
format!(
"CARGO_FEATURE_{}",
feature.to_uppercase().replace(['-', '.'], "_")
)
}
/// Emit one `cargo::warning=` naming every `[[test]]` target this feature
/// configuration will silently drop from a globbed `cargo test`.
///
/// The manifest is parsed rather than hard-coded so a target added later is
/// covered without anyone remembering to update this list — the same reason
/// the module list above is globbed instead of hand-maintained.
fn warn_about_silently_skipped_test_targets(manifest_dir: &Path) {
let manifest_path = manifest_dir.join("Cargo.toml");
println!("cargo:rerun-if-changed={}", manifest_path.display());
let Ok(manifest) = fs::read_to_string(&manifest_path) else {
// A source-only build tree may not ship the manifest we expect; the
// module list above already tolerates that shape.
return;
};
// Per target: what is missing NOW (the diagnostic) and what the target
// requires IN FULL (the remediation). They differ whenever a caller has
// some of the gates already, and conflating them is what made the
// suggested command wrong — see the `all` union below.
let mut skipped: BTreeMap<String, (Vec<String>, Vec<String>)> = BTreeMap::new();
for (target, required) in gated_test_targets(&manifest) {
let missing: Vec<String> = required
.iter()
.filter(|feature| {
let var = feature_env_var(feature);
// Re-run when the feature set changes, so switching profiles
// re-emits (or clears) the warning instead of leaving a stale
// cached result.
println!("cargo:rerun-if-env-changed={var}");
env::var_os(&var).is_none()
})
.cloned()
.collect();
if !missing.is_empty() {
skipped.insert(target, (missing, required));
}
}
if skipped.is_empty() {
return;
}
let detail = skipped
.iter()
.map(|(target, (missing, _))| format!("{target} (needs {})", missing.join(" + ")))
.collect::<Vec<_>>()
.join(", ");
// The union of every skipped target's FULL `required-features`, not just
// the subset currently missing. `--features` is the complete set to
// activate, not a delta applied to the current one, so a command built from
// the missing subset drops the gates the caller already had: with `voice`
// on, `raw_coverage_all` is missing only `inference`, and
// `--features inference` would silently skip both voice targets again — the
// warning would be handing out a command that reproduces the exact failure
// it exists to prevent.
let all: Vec<&str> = {
let mut features: Vec<&str> = skipped
.values()
.flat_map(|(_, required)| required.iter().map(String::as_str))
.collect();
features.sort_unstable();
features.dedup();
features
};
let features_flag = all.join(",");
// One warning rather than one per target: this fires on every build in a
// configuration that omits the gates, and four lines of it would train
// people to scroll past.
println!(
"cargo:warning=A globbed `cargo test` SILENTLY SKIPS these targets in this feature \
configuration and still exits 0: {detail}. Run them with `--features {features_flag}`, \
or use the product set: cargo test --features \"$(bash \
scripts/ci/product-features.sh)\". Naming a target explicitly (cargo test --test \
json_rpc_e2e) errors instead of skipping — only the globbed form is silent."
);
}
/// Every `[[test]]` target in `manifest` that declares `required-features`.
///
/// Deliberately a small hand parse: `build.rs` has no dependencies, and adding
/// a TOML crate to the build graph to read four lines would cost every build.
/// The shape it accepts is the shape the manifest uses — `[[test]]`, then
/// `name = "..."` and `required-features = [...]` before the next table.
fn gated_test_targets(manifest: &str) -> Vec<(String, Vec<String>)> {
let mut found = Vec::new();
let mut in_test_table = false;
let mut name: Option<String> = None;
let mut required: Option<Vec<String>> = None;
let flush = |name: &mut Option<String>,
required: &mut Option<Vec<String>>,
found: &mut Vec<(String, Vec<String>)>| {
if let (Some(name), Some(required)) = (name.take(), required.take()) {
found.push((name, required));
}
};
for line in manifest.lines() {
let line = line.trim();
if line.starts_with('[') {
// Any new table ends the current `[[test]]` block.
flush(&mut name, &mut required, &mut found);
in_test_table = line == "[[test]]";
continue;
}
if !in_test_table {
continue;
}
if let Some(value) = line.strip_prefix("name") {
if let Some(value) = value.trim_start().strip_prefix('=') {
name = Some(value.trim().trim_matches('"').to_string());
}
} else if let Some(value) = line.strip_prefix("required-features") {
if let Some(value) = value.trim_start().strip_prefix('=') {
let inner = value.trim().trim_start_matches('[').trim_end_matches(']');
required = Some(
inner
.split(',')
.map(|feature| feature.trim().trim_matches('"').to_string())
.filter(|feature| !feature.is_empty())
.collect(),
);
}
}
}
flush(&mut name, &mut required, &mut found);
found
}