1use std::{
2 collections::BTreeMap,
3 env, fs,
4 path::{Path, PathBuf},
5};
6
7use anyhow::{Context, Result, bail};
8use indexmap::IndexMap;
9use itertools::{self, Itertools};
10use serde::{Deserialize, Serialize};
11use serde_yaml::Value;
12
13use crate::{
14 app,
15 utils::{environment::Environment, paths},
16};
17
18const FILE_NAME: &str = "test.yaml";
19const CONFIG_SUBDIR: &str = "config";
20
21pub const INTEGRATION_TESTS_DIR: &str = "integration";
22pub const E2E_TESTS_DIR: &str = "e2e";
23
24fn test_dir_config(root_dir: &str) -> (&'static str, bool) {
28 if root_dir == INTEGRATION_TESTS_DIR {
29 ("tests", true)
30 } else {
31 ("scripts", false)
32 }
33}
34
35#[derive(Deserialize, Debug)]
36pub struct RustToolchainRootConfig {
37 pub toolchain: RustToolchainConfig,
38}
39
40#[derive(Deserialize, Debug)]
41pub struct RustToolchainConfig {
42 pub channel: String,
43}
44
45impl RustToolchainConfig {
46 fn parse() -> Result<Self> {
47 let repo_path = app::path();
48 let config_file: PathBuf = [repo_path, "rust-toolchain.toml"].iter().collect();
49 let contents = fs::read_to_string(&config_file)
50 .with_context(|| format!("failed to read {}", config_file.display()))?;
51 let config: RustToolchainRootConfig = toml::from_str(&contents)
52 .with_context(|| format!("failed to parse {}", config_file.display()))?;
53
54 Ok(config.toolchain)
55 }
56
57 pub fn rust_version() -> String {
58 match RustToolchainConfig::parse() {
59 Ok(config) => config.channel,
60 Err(error) => fatal!("Could not read `rust-toolchain.toml` file: {error}"),
61 }
62 }
63}
64
65#[derive(Debug, Deserialize, Serialize)]
66#[serde(untagged)]
67pub enum VolumeMount {
68 Short(String),
69 Long {
70 #[serde(default)]
71 r#type: Option<String>,
72 source: String,
73 target: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 read_only: Option<bool>,
76 },
77}
78
79#[derive(Debug, Deserialize, Serialize)]
80#[serde(untagged)]
81pub enum VolumeDefinition {
82 Empty,
83 WithOptions(BTreeMap<String, Value>),
84}
85
86#[derive(Debug, Deserialize, Serialize)]
87pub struct ComposeConfig {
88 pub services: BTreeMap<String, ComposeService>,
89 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90 pub volumes: BTreeMap<String, VolumeDefinition>,
91 #[serde(default)]
92 pub networks: BTreeMap<String, BTreeMap<String, Value>>,
93}
94
95#[derive(Debug, Deserialize, Serialize)]
96#[serde(untagged)]
97pub enum DependsOn {
98 Simple(Vec<String>),
99 Conditional(BTreeMap<String, DependencyCondition>),
100}
101
102#[derive(Debug, Deserialize, Serialize)]
103pub struct DependencyCondition {
104 pub condition: String,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub required: Option<bool>,
107}
108
109#[derive(Debug, Deserialize, Serialize)]
110pub struct ComposeService {
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub image: Option<String>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub hostname: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub container_name: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub build: Option<Value>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub command: Option<Command>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub ports: Option<Vec<String>>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub env_file: Option<Vec<String>>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub volumes: Option<Vec<VolumeMount>>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub environment: Option<Vec<String>>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub depends_on: Option<DependsOn>,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub healthcheck: Option<Value>,
133}
134
135#[derive(Debug, Deserialize, Serialize)]
136#[serde(untagged)]
137pub enum Command {
138 Single(String),
139 Multiple(Vec<String>),
140}
141
142impl ComposeConfig {
143 pub fn parse(path: &Path) -> Result<Self> {
144 let contents = fs::read_to_string(path)
145 .with_context(|| format!("failed to read {}", path.display()))?;
146 serde_yaml::from_str(&contents)
147 .with_context(|| format!("failed to parse {}", path.display()))
148 }
149}
150
151#[derive(Clone, Debug, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct ComposeTestConfig {
154 pub args: Option<Vec<String>>,
156 #[serde(default)]
160 pub env: Environment,
161 matrix: IndexMap<String, Vec<String>>,
163 #[serde(default)]
165 pub runner: IntegrationRunnerConfig,
166
167 pub features: Vec<String>,
168
169 pub test: Option<String>,
170
171 pub test_filter: Option<String>,
172
173 pub paths: Option<Vec<String>>,
174}
175
176#[derive(Clone, Debug, Default, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct IntegrationRunnerConfig {
179 #[serde(default)]
182 pub env: Environment,
183 #[serde(default)]
185 pub volumes: BTreeMap<String, String>,
186 #[serde(default)]
188 pub needs_docker_socket: bool,
189}
190
191impl ComposeTestConfig {
192 fn parse_file(config_file: &Path) -> Result<Self> {
193 let contents = fs::read_to_string(config_file)
194 .with_context(|| format!("failed to read {}", config_file.display()))?;
195 let config: Self = serde_yaml::from_str(&contents).with_context(|| {
196 format!(
197 "failed to parse integration test configuration file {}",
198 config_file.display()
199 )
200 })?;
201
202 Ok(config)
203 }
204
205 pub fn environments(&self) -> IndexMap<String, Environment> {
206 self.matrix
207 .values()
208 .multi_cartesian_product()
209 .map(|product| {
210 let key = product.iter().join("-");
211 let config: Environment = self
212 .matrix
213 .keys()
214 .zip(product)
215 .map(|(variable, value)| (variable.clone(), Some(value.clone())))
216 .collect();
217 (key, config)
218 })
219 .collect()
220 }
221
222 pub fn load(root_dir: &str, integration: &str) -> Result<(PathBuf, Self)> {
223 let (base_dir, use_config_subdir) = test_dir_config(root_dir);
224
225 let test_dir: PathBuf = [app::path(), base_dir, root_dir, integration]
226 .iter()
227 .collect();
228
229 if !test_dir.is_dir() {
230 bail!("unknown integration: {}", integration);
231 }
232
233 let config_dir = if use_config_subdir {
234 test_dir.join(CONFIG_SUBDIR)
235 } else {
236 test_dir.clone()
237 };
238 let config = Self::parse_file(&config_dir.join(FILE_NAME))?;
239 Ok((config_dir, config))
240 }
241
242 fn collect_all_dir(
243 tests_dir: &Path,
244 configs: &mut BTreeMap<String, Self>,
245 use_config_subdir: bool,
246 ) -> Result<()> {
247 for entry in tests_dir.read_dir()? {
248 let entry = entry?;
249 if entry.path().is_dir() {
250 let config_file: PathBuf = if use_config_subdir {
251 [entry.path().to_str().unwrap(), CONFIG_SUBDIR, FILE_NAME]
252 .iter()
253 .collect()
254 } else {
255 [entry.path().to_str().unwrap(), FILE_NAME].iter().collect()
256 };
257 if paths::exists(&config_file)? {
258 let config = Self::parse_file(&config_file)?;
259 configs.insert(entry.file_name().into_string().unwrap(), config);
260 }
261 }
262 }
263 Ok(())
264 }
265
266 pub fn collect_all(root_dir: &str) -> Result<BTreeMap<String, Self>> {
267 let mut configs = BTreeMap::new();
268
269 let (base_dir, use_config_subdir) = test_dir_config(root_dir);
270 let tests_dir: PathBuf = [app::path(), base_dir, root_dir].iter().collect();
271
272 Self::collect_all_dir(&tests_dir, &mut configs, use_config_subdir)?;
273
274 Ok(configs)
275 }
276
277 pub fn check_required(&self) -> Result<()> {
279 let missing: Vec<_> = self
280 .env
281 .iter()
282 .chain(self.runner.env.iter())
283 .filter_map(|(key, value)| value.is_none().then_some(key))
284 .filter(|var| env::var(var).is_err())
285 .collect();
286 if missing.is_empty() {
287 Ok(())
288 } else {
289 let missing = missing.into_iter().join(", ");
290 bail!("Required environment variables are not set: {missing}");
291 }
292 }
293}