1use std::collections::BTreeMap;
7use std::fmt;
8use std::path::Path;
9use std::str::FromStr;
10
11use anyhow::{Context, Result};
12use serde::Deserialize;
13
14use uv_configuration::TargetTriple;
15use uv_distribution_filename::WheelFilename;
16use uv_normalize::{ExtraName, PackageName};
17use uv_pep440::{Version, VersionSpecifiers};
18use uv_pep508::{MarkerTree, Requirement};
19use uv_python::PythonVersion;
20
21#[derive(Debug, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct Scenario {
25 pub name: String,
27
28 #[serde(default)]
30 pub description: Option<String>,
31
32 #[serde(default)]
34 pub packages: BTreeMap<PackageName, Package>,
35
36 pub root: RootPackage,
38
39 pub expected: Expected,
41
42 #[serde(default)]
44 pub environment: Environment,
45
46 #[serde(default)]
48 pub resolver_options: ResolverOptions,
49
50 #[serde(default)]
52 pub testgen: TestGeneration,
53}
54
55impl Scenario {
56 pub fn from_path(path: &Path) -> Result<Self> {
58 let contents = fs_err::read_to_string(path)
59 .with_context(|| format!("failed to read scenario file `{}`", path.display()))?;
60 toml::from_str(&contents)
61 .with_context(|| format!("failed to parse scenario file `{}`", path.display()))
62 }
63
64 pub fn empty() -> Self {
66 Self {
67 name: String::new(),
68 description: None,
69 packages: BTreeMap::new(),
70 root: RootPackage {
71 requires_python: None,
72 requires: Vec::new(),
73 },
74 expected: Expected {
75 satisfiable: true,
76 packages: BTreeMap::new(),
77 explanation: None,
78 },
79 environment: Environment::default(),
80 resolver_options: ResolverOptions::default(),
81 testgen: TestGeneration::default(),
82 }
83 }
84}
85
86#[derive(Debug, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct Package {
90 pub versions: BTreeMap<Version, PackageMetadata>,
91}
92
93#[derive(Debug, Deserialize, Default)]
95#[serde(deny_unknown_fields)]
96pub struct PackageMetadata {
97 #[serde(default = "default_requires_python")]
99 pub requires_python: Option<VersionSpecifiers>,
100
101 #[serde(default)]
103 pub requires: Vec<Requirement>,
104
105 #[serde(default)]
107 pub extras: BTreeMap<ExtraName, Vec<Requirement>>,
108
109 #[serde(default = "default_true")]
111 pub sdist: bool,
112
113 #[serde(default = "default_true")]
115 pub wheel: bool,
116
117 #[serde(default)]
119 pub yanked: bool,
120
121 #[serde(default)]
124 pub wheel_tags: Vec<WheelTag>,
125}
126
127#[derive(Clone, Debug)]
129pub struct WheelTag(String);
130
131impl WheelTag {
132 pub fn as_str(&self) -> &str {
134 &self.0
135 }
136}
137
138impl FromStr for WheelTag {
139 type Err = String;
140
141 fn from_str(tag: &str) -> Result<Self, Self::Err> {
142 if tag.split('-').count() != 3 {
143 return Err(format!(
144 "wheel tag `{tag}` must have exactly three components"
145 ));
146 }
147 WheelFilename::from_str(&format!("package-0-{tag}.whl"))
148 .map_err(|error| format!("wheel tag `{tag}` is invalid: {error}"))?;
149 Ok(Self(tag.to_string()))
150 }
151}
152
153impl<'de> Deserialize<'de> for WheelTag {
154 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
155 where
156 D: serde::Deserializer<'de>,
157 {
158 let tag = String::deserialize(deserializer)?;
159 Self::from_str(&tag).map_err(serde::de::Error::custom)
160 }
161}
162
163#[derive(Debug, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub struct RootPackage {
167 #[serde(default = "default_requires_python")]
169 pub requires_python: Option<VersionSpecifiers>,
170
171 #[serde(default)]
173 pub requires: Vec<Requirement>,
174}
175
176#[derive(Debug, Deserialize)]
178#[serde(deny_unknown_fields)]
179pub struct Expected {
180 pub satisfiable: bool,
182
183 #[serde(default)]
185 pub packages: BTreeMap<PackageName, Version>,
186
187 #[serde(default)]
189 pub explanation: Option<String>,
190}
191
192#[derive(Debug, Deserialize)]
194#[serde(deny_unknown_fields)]
195pub struct Environment {
196 #[serde(default = "default_python")]
198 pub python: PythonVersion,
199
200 #[serde(default)]
202 pub additional_python: Vec<PythonVersion>,
203}
204
205impl Default for Environment {
206 fn default() -> Self {
207 Self {
208 python: default_python(),
209 additional_python: Vec::new(),
210 }
211 }
212}
213
214#[derive(Debug, Default, Deserialize)]
216#[serde(deny_unknown_fields)]
217pub struct ResolverOptions {
218 #[serde(default)]
220 pub resolution: Option<Resolution>,
221
222 #[serde(default)]
224 pub python: Option<PythonVersion>,
225
226 #[serde(default)]
228 pub prereleases: bool,
229
230 #[serde(default)]
232 pub no_build: Vec<PackageName>,
233
234 #[serde(default)]
236 pub no_binary: Vec<PackageName>,
237
238 #[serde(default)]
240 pub universal: bool,
241
242 #[serde(default)]
244 pub python_platform: Option<TargetTriple>,
245
246 #[serde(default)]
248 pub required_environments: Vec<MarkerTree>,
249}
250
251#[derive(Debug, Default, Deserialize)]
253#[serde(deny_unknown_fields)]
254pub struct TestGeneration {
255 #[serde(default)]
257 pub disable: bool,
258
259 #[serde(default)]
261 pub kind: Option<ScenarioTest>,
262}
263
264#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
266#[serde(rename_all = "kebab-case")]
267pub enum ScenarioTest {
268 Install,
269 Compile,
270 Lock,
271}
272
273#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
275#[serde(rename_all = "kebab-case")]
276pub enum Resolution {
277 Highest,
278 Lowest,
279 LowestDirect,
280}
281
282impl fmt::Display for Resolution {
283 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
284 match self {
285 Self::Highest => formatter.write_str("highest"),
286 Self::Lowest => formatter.write_str("lowest"),
287 Self::LowestDirect => formatter.write_str("lowest-direct"),
288 }
289 }
290}
291
292#[expect(clippy::unnecessary_wraps)] fn default_requires_python() -> Option<VersionSpecifiers> {
294 Some(VersionSpecifiers::from_str(">=3.12").expect("default requires-python should be valid"))
295}
296
297fn default_true() -> bool {
298 true
299}
300
301fn default_python() -> PythonVersion {
302 PythonVersion::from_str("3.12").expect("default Python version should be valid")
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn parse_basic_scenario() {
311 let toml = r#"
312name = "fork-basic"
313description = "An extremely basic test."
314
315[resolver_options]
316universal = true
317
318[expected]
319satisfiable = true
320
321[root]
322requires = ["a>=2 ; sys_platform == 'linux'", "a<2 ; sys_platform == 'darwin'"]
323
324[packages.a.versions."1.0.0"]
325[packages.a.versions."2.0.0"]
326"#;
327 let scenario: Scenario = toml::from_str(toml).expect("scenario should parse");
328 let package_name = PackageName::from_str("a").expect("valid package name");
329 assert_eq!(scenario.name, "fork-basic");
330 assert!(scenario.resolver_options.universal);
331 assert_eq!(scenario.packages.len(), 1);
332 assert_eq!(scenario.packages[&package_name].versions.len(), 2);
333 }
334
335 #[test]
336 fn parse_extras_scenario() {
337 let toml = r#"
338name = "all-extras-required"
339description = "Multiple optional dependencies."
340
341[root]
342requires = ["a[all]"]
343
344[expected]
345satisfiable = true
346
347[expected.packages]
348a = "1.0.0"
349b = "1.0.0"
350c = "1.0.0"
351
352[packages.b.versions."1.0.0"]
353[packages.c.versions."1.0.0"]
354
355[packages.a.versions."1.0.0".extras]
356all = ["a[extra_b]", "a[extra_c]"]
357extra_b = ["b"]
358extra_c = ["c"]
359"#;
360 let scenario: Scenario = toml::from_str(toml).expect("scenario should parse");
361 let package_name = PackageName::from_str("a").expect("valid package name");
362 let version = Version::from_str("1.0.0").expect("valid version");
363 let extra_name = ExtraName::from_str("extra_b").expect("valid extra name");
364 assert_eq!(scenario.name, "all-extras-required");
365 let a_meta = &scenario.packages[&package_name].versions[&version];
366 assert_eq!(a_meta.extras.len(), 3);
367 assert_eq!(
368 a_meta.extras[&extra_name],
369 vec![Requirement::from_str("b").expect("valid requirement")]
370 );
371 }
372
373 #[test]
374 fn parse_test_and_resolution() {
375 let toml = r#"
376name = "lowest-direct"
377
378[root]
379requires = ["a"]
380
381[expected]
382satisfiable = true
383
384[testgen]
385kind = "compile"
386
387[resolver_options]
388resolution = "lowest-direct"
389"#;
390 let scenario: Scenario = toml::from_str(toml).expect("scenario should parse");
391 assert_eq!(scenario.testgen.kind, Some(ScenarioTest::Compile));
392 assert_eq!(
393 scenario.resolver_options.resolution,
394 Some(Resolution::LowestDirect)
395 );
396 }
397
398 #[test]
399 fn reject_invalid_requires_python() {
400 let toml = r#"
401name = "invalid-requires-python"
402
403[root]
404requires = []
405
406[expected]
407satisfiable = true
408
409[packages.a.versions."1.0.0"]
410requires_python = "not a specifier"
411"#;
412
413 assert!(toml::from_str::<Scenario>(toml).is_err());
414 }
415
416 #[test]
417 fn reject_unknown_metadata_field() {
418 let toml = r#"
419name = "unknown-metadata-field"
420
421[root]
422requires = ["a"]
423
424[expected]
425satisfiable = true
426
427[packages.a.versions."1.0.0"]
428wheels = false
429"#;
430
431 assert!(toml::from_str::<Scenario>(toml).is_err());
432 }
433
434 #[test]
435 fn reject_invalid_wheel_tag() {
436 let toml = r#"
437name = "invalid-wheel-tag"
438
439[root]
440requires = ["a"]
441
442[expected]
443satisfiable = true
444
445[packages.a.versions."1.0.0"]
446wheel_tags = ["1-py3-none-any"]
447"#;
448
449 assert!(toml::from_str::<Scenario>(toml).is_err());
450 }
451
452 #[test]
453 fn path_is_included_in_parse_errors() {
454 let temporary_directory =
455 tempfile::tempdir().expect("temporary directory should be created");
456 let path = temporary_directory.path().join("invalid.toml");
457 fs_err::write(&path, "not valid TOML = [").expect("invalid scenario should be written");
458
459 let error = Scenario::from_path(&path).expect_err("scenario should fail to parse");
460 insta::assert_snapshot!(
461 error
462 .to_string()
463 .replace(temporary_directory.path().to_string_lossy().as_ref(), "[TEMP_DIR]")
464 .replace('\\', "/"),
465 @"failed to parse scenario file `[TEMP_DIR]/invalid.toml`"
466 );
467 }
468}