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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
//! `tm doctor --fix` for the `rust_build_env` row (#6868).
//!
//! Why: the row's two findings are both one-line fixes an operator should not
//! have to perform by hand — the shared target directory does not exist yet,
//! and the `build:` section has never been written, so the values the row
//! reports live nowhere the operator can edit them. This is the arm that closes
//! both.
//!
//! What: [`repair_rust_build_env`] emits at most three [`RepairStep`]s — create
//! [`ResolvedBuildEnv::cargo_target_dir`] with its parents, SEED the `build:`
//! section into `~/.trusty-tools/trusty-mpm/config.yaml` when and only when no
//! top-level `build` key is present, and a standing REFUSAL for
//! `~/.cargo/config.toml`.
//!
//! Three rules bound it, and they are why it is this small:
//!
//! 1. **Never overwrite an existing key.** The seed is skipped entirely when
//! the document already carries `build:`, however partial that section is.
//! The repair appends TEXT rather than re-serialising
//! [`crate::core::trusty_tools_config::TrustyToolsConfig`],
//! because that loader is lenient by design (#5207) and a round-trip would
//! silently drop every key the schema does not define, plus every comment.
//! 2. **`~/.cargo/config.toml` is never edited.** That file is machine-global
//! for every Rust project on the host, not just tm's, so writing
//! `build.rustc-wrapper` there changes builds nobody ran through tm. The
//! refusal is reported with its reason rather than left silent — the same
//! convention [`crate::core::doctor_repair::refuse_legacy_sources`] uses.
//! 3. **Fail closed, and verify from disk.** A config file that does not parse
//! is REFUSED, never appended to; and an applied write is read back before
//! it is reported applied, so a silent no-op cannot render as a repair.
//!
//! Test: `build_env_repair_tests.rs`.
use std::path::{Path, PathBuf};
use crate::core::build_env::{BUILD_SECTION_KEY, ResolvedBuildEnv, default_build_section_yaml};
use crate::core::doctor_repair::{RepairMode, RepairStep, StepStatus};
/// The `tm doctor` check every step here answers.
///
/// Why: one literal, shared with `daemon::doctor_rust_build_env::CHECK_NAME`,
/// so a `--fix` line and the row that reported it read the same name.
/// What: `rust_build_env`.
/// Test: `rust_build_env_repair_steps_name_the_check`.
pub const CHECK_NAME: &str = "rust_build_env";
/// Why `--fix` will not wire sccache for the operator.
///
/// Why: an operator who sees no sccache step needs to know it was a decision,
/// not an omission. Naming the string makes it assertable.
/// What: one sentence, used verbatim in the refused step.
/// Test: `rust_build_env_repair_refuses_to_wire_the_cargo_config`.
pub const CARGO_CONFIG_REFUSAL: &str = "`~/.cargo/config.toml` is machine-global for every Rust project on this host, not just \
tm's — wiring `build.rustc-wrapper` there changes builds tm never ran, so it stays an \
operator decision";
/// Create the shared target directory and seed the `build:` section.
///
/// Why: see the module doc. `--fix` is the only surface between launches where
/// a machine-level build setting can be established, so it is where the two
/// repairable halves of the `rust_build_env` row get fixed.
/// What: up to three steps, in this order — the directory, the config seed, and
/// the standing `~/.cargo/config.toml` refusal (emitted only when the config
/// asked for sccache, since there is nothing to refuse otherwise). A directory
/// that already exists and a document that already carries `build:` each
/// produce NO step, which is what makes a second run report nothing to repair.
/// `config_path` is threaded in rather than resolved here so every test is
/// hermetic.
/// Test: `rust_build_env_repair_creates_dir_and_writes_defaults_once`,
/// `rust_build_env_repair_is_idempotent`,
/// `rust_build_env_repair_never_overwrites_existing_build_keys`,
/// `rust_build_env_repair_refuses_an_unparseable_config`,
/// `rust_build_env_repair_refuses_to_wire_the_cargo_config`,
/// `rust_build_env_repair_reports_an_uncreatable_directory_as_failed`.
pub fn repair_rust_build_env(
config_path: &Path,
env: &ResolvedBuildEnv,
mode: RepairMode,
) -> Vec<RepairStep> {
let mut steps = Vec::new();
if let Some(step) = target_dir_step(&env.cargo_target_dir, mode) {
steps.push(step);
}
if let Some(step) = config_seed_step(config_path, env, mode) {
steps.push(step);
}
if env.sccache {
steps.push(RepairStep {
check: CHECK_NAME,
path: cargo_config_path_for(config_path),
what: "wire `build.rustc-wrapper = \"sccache\"`".to_string(),
status: StepStatus::Refused(CARGO_CONFIG_REFUSAL.to_string()),
});
}
steps
}
/// Where `~/.cargo/config.toml` lives, relative to the trusty-tools config.
///
/// Why: the refusal step must name a real path, and the repair is hermetic —
/// it never calls `dirs::home_dir()`. The trusty-tools config already sits at
/// `<home>/.trusty-tools/trusty-mpm/config.yaml`, so the home directory is
/// three levels up from it.
/// What: `<home>/.cargo/config.toml`, falling back to the bare relative path
/// when `config_path` is too shallow to contain a home directory.
/// Test: `rust_build_env_repair_refuses_to_wire_the_cargo_config`.
fn cargo_config_path_for(config_path: &Path) -> PathBuf {
let home = config_path
.parent()
.and_then(Path::parent)
.and_then(Path::parent);
match home {
Some(home) => home.join(".cargo").join("config.toml"),
None => PathBuf::from(".cargo/config.toml"),
}
}
/// The directory half — one step, or none when it already exists.
///
/// Why: idempotence is what lets an operator run `--fix` without first working
/// out whether it is needed, so "already there" must be NO step rather than a
/// step reporting a creation that did not happen.
/// What: `None` when `dir` is an existing directory. Otherwise a step that
/// creates it with parents; a path that exists and is NOT a directory is
/// REFUSED rather than replaced, because tm never deletes what it did not
/// write.
/// Test: `rust_build_env_repair_creates_dir_and_writes_defaults_once`,
/// `rust_build_env_repair_is_idempotent`,
/// `rust_build_env_repair_reports_an_uncreatable_directory_as_failed`.
fn target_dir_step(dir: &Path, mode: RepairMode) -> Option<RepairStep> {
if dir.is_dir() {
return None;
}
let what = "create the shared cargo target directory".to_string();
if dir.exists() {
return Some(RepairStep {
check: CHECK_NAME,
path: dir.to_path_buf(),
what,
status: StepStatus::Refused(
"the path exists and is not a directory — tm does not remove what it did not \
write"
.to_string(),
),
});
}
if mode == RepairMode::DryRun {
return Some(RepairStep {
check: CHECK_NAME,
path: dir.to_path_buf(),
what,
status: StepStatus::Planned,
});
}
let status = match std::fs::create_dir_all(dir) {
// Verified from disk, never from the `Ok` alone.
Ok(()) if dir.is_dir() => StepStatus::Applied { backup: None },
Ok(()) => StepStatus::Failed(
"create_dir_all reported success but the directory is not there".to_string(),
),
Err(e) => StepStatus::Failed(e.to_string()),
};
Some(RepairStep {
check: CHECK_NAME,
path: dir.to_path_buf(),
what,
status,
})
}
/// The config half — seed `build:` when, and only when, it is absent.
///
/// Why: rule 1 of the module doc. Detection is by TOP-LEVEL KEY rather than by
/// field, so a section carrying only `build_jobs` is still "present" and keeps
/// every value the operator chose, including the ones they left unset on
/// purpose.
/// What: `None` when the document already has a `build` key. A missing file is
/// created (with parents) carrying only the seeded block. An existing file is
/// APPENDED to, after a byte-identical backup, with a leading newline when the
/// file does not already end in one. A file that does not parse as a YAML
/// mapping is REFUSED — appending to a document tm cannot read risks producing
/// a second document it can read even less.
/// Test: `rust_build_env_repair_creates_dir_and_writes_defaults_once`,
/// `rust_build_env_repair_never_overwrites_existing_build_keys`,
/// `rust_build_env_repair_refuses_an_unparseable_config`,
/// `rust_build_env_repair_seeds_an_absent_config_file`.
fn config_seed_step(
config_path: &Path,
env: &ResolvedBuildEnv,
mode: RepairMode,
) -> Option<RepairStep> {
let existing = match std::fs::read_to_string(config_path) {
Ok(raw) => Some(raw),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Some(RepairStep {
check: CHECK_NAME,
path: config_path.to_path_buf(),
what: seed_description(),
status: StepStatus::Refused(format!("config file is unreadable: {e}")),
});
}
};
if let Some(raw) = &existing
&& !raw.trim().is_empty()
{
match crate::core::config_keys::yaml_document(raw) {
Some(doc) if doc.get(BUILD_SECTION_KEY).is_some() => return None,
Some(_) => {}
None => {
return Some(RepairStep {
check: CHECK_NAME,
path: config_path.to_path_buf(),
what: seed_description(),
status: StepStatus::Refused(
"the config file does not parse as a YAML mapping — fix it by hand \
before `--fix` appends to it"
.to_string(),
),
});
}
}
}
if mode == RepairMode::DryRun {
return Some(RepairStep {
check: CHECK_NAME,
path: config_path.to_path_buf(),
what: seed_description(),
status: StepStatus::Planned,
});
}
Some(RepairStep {
check: CHECK_NAME,
path: config_path.to_path_buf(),
what: seed_description(),
status: apply_seed(config_path, existing.as_deref(), env),
})
}
/// One line describing the seed, identical in both modes.
///
/// Why: [`RepairStep`]'s contract is that `what` does not depend on the mode,
/// so the preview and the apply read the same.
/// Test: `rust_build_env_repair_creates_dir_and_writes_defaults_once`.
fn seed_description() -> String {
format!("seed the `{BUILD_SECTION_KEY}:` defaults (no `{BUILD_SECTION_KEY}` key present)")
}
/// Perform the seed and verify it back off disk.
///
/// Why: a write that returned `Ok` has not been shown to have landed — a
/// repair that reports applied without reading the file back is the failure
/// mode `doctor_repair_scope`'s module doc names.
/// What: backs up an existing file first, appends the block (with a separating
/// newline when needed), then re-reads and re-parses the file and confirms the
/// `build` key is now present.
/// Test: `rust_build_env_repair_creates_dir_and_writes_defaults_once`,
/// `rust_build_env_repair_seeds_an_absent_config_file`.
fn apply_seed(config_path: &Path, existing: Option<&str>, env: &ResolvedBuildEnv) -> StepStatus {
let backup = match existing {
Some(raw) => {
let path = backup_path(config_path);
if let Err(e) = std::fs::write(&path, raw) {
return StepStatus::Failed(format!("could not back up the config file: {e}"));
}
Some(path)
}
None => {
if let Some(parent) = config_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return StepStatus::Failed(format!("could not create the config directory: {e}"));
}
None
}
};
let mut next = existing.unwrap_or_default().to_string();
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
if !next.is_empty() {
next.push('\n');
}
next.push_str(&default_build_section_yaml(env));
if let Err(e) = std::fs::write(config_path, &next) {
return StepStatus::Failed(e.to_string());
}
match std::fs::read_to_string(config_path)
.ok()
.as_deref()
.and_then(crate::core::config_keys::yaml_document)
{
Some(doc) if doc.get(BUILD_SECTION_KEY).is_some() => StepStatus::Applied { backup },
_ => StepStatus::Failed(
"the config file was written but no `build` key reads back from it".to_string(),
),
}
}
/// `<config.yaml>.bak-<unix_nanos>`, beside the file it copies.
///
/// Why: the same `<file>.bak-<nanos>` convention
/// [`crate::core::agent_reset`] uses, so an operator finds backups in one shape
/// across every repair.
/// Test: `rust_build_env_repair_creates_dir_and_writes_defaults_once`.
fn backup_path(target: &Path) -> PathBuf {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let name = target
.file_name()
.map_or_else(|| "config.yaml".to_string(), |n| n.to_string_lossy().into());
target.with_file_name(format!("{name}.bak-{ts}"))
}
#[cfg(test)]
#[path = "build_env_repair_tests.rs"]
mod tests;