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
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
//! Check for symbolic link validity.
use crate::core::metadata::Metadata;
use crate::paths;
use crate::uv::UvClient;
use super::super::types::{Check, CheckResult, CheckStatus};
/// Check for symbolic link validity.
pub(super) struct SymlinkCheck;
impl Check for SymlinkCheck {
fn id(&self) -> &'static str {
"symlink"
}
fn name(&self) -> &'static str {
"symbolic links"
}
fn run(&self) -> Vec<CheckResult> {
let venvs_dir = match paths::virtualenvs_dir() {
Ok(dir) if dir.exists() => dir,
_ => return vec![],
};
let mut results = Vec::new();
let mut valid = 0;
let mut broken_names = Vec::new();
if let Ok(entries) = std::fs::read_dir(&venvs_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let python_path = crate::paths::virtualenv_python_exe(&path);
if python_path.is_symlink() {
match std::fs::read_link(&python_path) {
Ok(target) if target.exists() => {
valid += 1;
}
Ok(_) => {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
broken_names.push(name);
}
Err(_) => {
// Not a symlink or error reading
}
}
}
}
}
}
// Report broken symlinks
for name in &broken_names {
results.push(
CheckResult::error(
"symlink",
"broken symlink",
format!("Python symlink in '{}' is broken", name),
)
.with_suggestion(format!(
"scuv remove {} && scuv create {} <python-version>",
name, name
)),
);
}
// Summary
if broken_names.is_empty() && valid > 0 {
results.push(
CheckResult::ok(self.id(), self.name())
.with_details(format!("{} symlinks valid", valid)),
);
}
results
}
fn fix(&self, result: &CheckResult, output: &crate::output::Output) -> Option<CheckResult> {
// Extract environment name from error message: "Python symlink in 'name' is broken"
let venv_name = if let CheckStatus::Error(msg) = &result.status {
// Parse: "Python symlink in 'name' is broken"
msg.split('\'').nth(1).map(|s| s.to_string())
} else {
None
}?;
output.info(&format!("Attempting to fix symlink for '{}'...", venv_name));
// Get virtualenv path
let venvs_dir = paths::virtualenvs_dir().ok()?;
let venv_path = venvs_dir.join(&venv_name);
if !venv_path.exists() {
return Some(
CheckResult::error(
"symlink",
"broken symlink",
format!("environment '{}' not found", venv_name),
)
.with_suggestion(format!("scuv create {} <python-version>", venv_name)),
);
}
// Read the venv's Python version (scuv metadata, then pyvenv.cfg fallback).
let python_version = read_python_version(&venv_path);
let python_version = match python_version {
Some(v) => v,
None => {
return Some(
CheckResult::error(
"symlink",
"broken symlink",
format!("could not determine Python version for '{}'", venv_name),
)
.with_suggestion(format!(
"scuv remove {} && scuv create {} <python-version>",
venv_name, venv_name
)),
);
}
};
output.info(&format!("Found Python version: {}", python_version));
// Find Python binary using uv
let uv = match UvClient::new() {
Ok(uv) => uv,
Err(_) => {
return Some(
CheckResult::error("symlink", "broken symlink", "uv not available")
.with_suggestion("Install uv first"),
);
}
};
let python_path = match uv.find_python(&python_version) {
Ok(Some(info)) => match info.path {
Some(path) => path,
None => {
return Some(
CheckResult::error(
"symlink",
"broken symlink",
format!("Python {} path not found", python_version),
)
.with_suggestion(format!("scuv install {}", python_version)),
);
}
},
Ok(None) => {
return Some(
CheckResult::error(
"symlink",
"broken symlink",
format!("Python {} not installed", python_version),
)
.with_suggestion(format!("scuv install {}", python_version)),
);
}
Err(_) => {
return Some(
CheckResult::error(
"symlink",
"broken symlink",
"failed to find Python installation",
)
.with_suggestion(format!("scuv install {}", python_version)),
);
}
};
// Recreate symlink
let symlink_path = crate::paths::virtualenv_python_exe(&venv_path);
// Remove old symlink if exists
if symlink_path.exists() || symlink_path.is_symlink() {
if let Err(e) = std::fs::remove_file(&symlink_path) {
return Some(
CheckResult::error(
"symlink",
"broken symlink",
format!("failed to remove old symlink: {}", e),
)
.with_suggestion("Check file permissions"),
);
}
}
// Create new symlink
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
if let Err(e) = symlink(&python_path, &symlink_path) {
return Some(
CheckResult::error(
"symlink",
"broken symlink",
format!("failed to create symlink: {}", e),
)
.with_suggestion("Check file permissions"),
);
}
}
#[cfg(not(unix))]
{
return Some(
CheckResult::warn(
"symlink",
"broken symlink",
"symlink fix not supported on this platform",
)
.with_suggestion("Manually recreate the symlink"),
);
}
output.success(&format!("Fixed symlink for '{}'", venv_name));
Some(
CheckResult::ok("symlink", "broken symlink")
.with_details(format!("fixed symlink for '{}'", venv_name)),
)
}
}
/// Reads the venv's Python version from its scuv metadata, falling back to
/// parsing `pyvenv.cfg`. Returns `None` if neither source yields a version.
fn read_python_version(venv_path: &std::path::Path) -> Option<String> {
let metadata_path = venv_path.join(Metadata::FILE_NAME);
if metadata_path.exists() {
match std::fs::read_to_string(&metadata_path) {
Ok(content) => match serde_json::from_str::<Metadata>(&content) {
Ok(meta) => Some(meta.python_version),
Err(_) => None,
},
Err(_) => None,
}
} else {
// Try to extract from pyvenv.cfg as fallback.
let pyvenv_cfg = venv_path.join("pyvenv.cfg");
if pyvenv_cfg.exists() {
std::fs::read_to_string(&pyvenv_cfg)
.ok()
.and_then(|content| {
for line in content.lines() {
if line.starts_with("version") {
return line.split('=').nth(1).map(|v| v.trim().to_string());
}
}
None
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::with_temp_scoop_home;
use serial_test::serial;
#[test]
fn symlink_check_reports_identity() {
// Pins id()/name() directly; run()'s healthy summary (which also uses
// them) is excluded from mutants for its equivalent dir-guard mutation,
// so this is the killing test for the id/name mutants.
let check = SymlinkCheck;
assert_eq!(check.id(), "symlink");
assert_eq!(check.name(), "symbolic links");
}
#[test]
fn read_python_version_prefers_scuv_metadata() {
// Metadata present -> the version comes from it.
let tmp = tempfile::tempdir().unwrap();
let meta = Metadata::new("env".to_string(), "3.12.5".to_string(), None);
std::fs::write(
tmp.path().join(Metadata::FILE_NAME),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
assert_eq!(read_python_version(tmp.path()).as_deref(), Some("3.12.5"));
}
#[test]
fn read_python_version_falls_back_to_pyvenv_cfg() {
// No metadata -> parse the `version = X` line from pyvenv.cfg.
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("pyvenv.cfg"),
"home = /usr\nversion = 3.11.9\n",
)
.unwrap();
assert_eq!(read_python_version(tmp.path()).as_deref(), Some("3.11.9"));
}
#[test]
fn read_python_version_none_when_no_sources() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(read_python_version(tmp.path()), None);
}
#[test]
fn read_python_version_none_on_corrupt_metadata() {
// Corrupt metadata does NOT fall through to pyvenv.cfg (original behavior).
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(Metadata::FILE_NAME), "{ not json").unwrap();
std::fs::write(tmp.path().join("pyvenv.cfg"), "version = 9.9.9\n").unwrap();
assert_eq!(read_python_version(tmp.path()), None);
}
/// On Unix we can deterministically create a symlink whose target
/// doesn't exist, which is exactly the failure SymlinkCheck::run
/// surfaces. cfg-gated to Unix because the symlink primitive
/// differs on Windows (and the CI matrix that exercises mutants is
/// Linux only — same rationale as the existing
/// test_virtualenv_exists_with_broken_symlink in paths.rs).
#[cfg(unix)]
#[test]
#[serial]
fn symlink_check_emits_error_for_broken_python_symlink() {
with_temp_scoop_home(|temp| {
use std::os::unix::fs::symlink;
let env = temp.path().join("virtualenvs").join("brokenlink");
std::fs::create_dir_all(env.join("bin")).unwrap();
symlink("/nonexistent/python", env.join("bin").join("python")).unwrap();
let results = SymlinkCheck.run();
assert!(
!results.is_empty(),
"broken symlink env must produce at least one result"
);
assert!(
results.iter().any(|r| r.is_error()),
"expected at least one error result, got {results:#?}"
);
});
}
#[test]
#[serial]
fn fix_symlink_returns_some_for_parseable_error_name() {
with_temp_scoop_home(|temp| {
// SymlinkCheck::fix parses the env name out of a "Python symlink
// in 'name' is broken" message. If the parse succeeds and
// the env doesn't exist, it returns Some(error suggesting
// scuv create). The cargo-mutants -> None replacement
// would silently drop that guidance.
let broken = temp.path().join("virtualenvs");
std::fs::create_dir_all(&broken).unwrap();
let probe = CheckResult::error(
"symlink",
"broken symlink",
"Python symlink in 'fix-target' is broken".to_string(),
);
let output = crate::output::Output::new(0, true, true, false);
let fixed = SymlinkCheck.fix(&probe, &output);
assert!(fixed.is_some(), "fix_symlink must return Some");
let r = fixed.unwrap();
assert!(r.is_error() || r.is_warning());
// Suggestion text should point the user at `scuv create`.
assert!(
r.suggestion
.as_deref()
.is_some_and(|s| s.contains("scuv create"))
|| matches!(&r.status, CheckStatus::Error(msg) if msg.contains("fix-target"))
);
});
}
}