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