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