1use serde::Deserialize;
13use std::collections::HashMap;
14use std::path::Path;
15use tropel_core::config::*;
16use tropel_sdk::{Result, TropelError};
17
18#[derive(Debug, Clone, Default, Deserialize)]
22#[serde(default)]
23pub struct PartialConfig {
24 pub input_type: Option<String>,
25 pub execution: Option<ExecutionConfig>,
26 pub scenarios: Option<HashMap<String, ScenarioConfig>>,
27 pub env: HashMap<String, String>,
28 pub globals: HashMap<String, serde_json::Value>,
29 pub collection_vars: HashMap<String, serde_json::Value>,
30 pub data_file: Option<String>,
31 pub iteration_data: Vec<HashMap<String, serde_json::Value>>,
32 pub thresholds: HashMap<String, ThresholdConfig>,
33 pub output: Option<OutputConfig>,
34 pub http: Option<HttpConfig>,
35 pub tls: Option<TlsConfig>,
36 pub extensions: HashMap<String, serde_json::Value>,
37 #[serde(default, alias = "executionSegment")]
39 pub execution_segment: Option<String>,
40 #[serde(default, alias = "executionSegmentSequence")]
42 pub execution_segment_sequence: Option<String>,
43 #[serde(default, alias = "controlPort")]
46 pub control_port: Option<u16>,
47}
48
49impl PartialConfig {
50 pub fn load_from_file(path: &Path) -> Result<Self> {
52 let content = std::fs::read_to_string(path).map_err(|e| {
53 TropelError::Io(std::io::Error::new(
54 e.kind(),
55 format!("Failed to read config file '{}': {e}", path.display()),
56 ))
57 })?;
58 serde_json::from_str(&content).map_err(|e| {
59 TropelError::Parse(format!(
60 "Failed to parse config file '{}': {e}",
61 path.display()
62 ))
63 })
64 }
65
66 pub fn from_env() -> Self {
79 let mut cfg = Self::default();
80
81 let k6_mode = env_str("K6_MODE");
82 let k6_vus = env_num::<u32>("K6_VUS");
83 let k6_duration = env_str("K6_DURATION");
84 let k6_iterations = env_num::<u64>("K6_ITERATIONS");
85 let k6_stages = env_str("K6_STAGES");
86
87 if let Some(v) = k6_mode.clone() {
88 cfg.env.insert("K6_MODE".into(), v);
89 }
90 if let Some(v) = k6_vus {
91 cfg.env.insert("K6_VUS".into(), v.to_string());
92 }
93 if let Some(v) = k6_duration.clone() {
94 cfg.env.insert("K6_DURATION".into(), v);
95 }
96 if let Some(v) = k6_iterations {
97 cfg.env.insert("K6_ITERATIONS".into(), v.to_string());
98 }
99 if let Some(v) = k6_stages.clone() {
100 cfg.env.insert("K6_STAGES".into(), v);
102 }
103
104 cfg.execution = env_execution(
105 k6_mode.as_deref(),
106 k6_vus,
107 k6_duration.as_deref(),
108 k6_iterations,
109 k6_stages.as_deref(),
110 );
111
112 if let Some(v) = env_str("K6_THRESHOLDS") {
113 if let Ok(map) = serde_json::from_str::<HashMap<String, ThresholdConfig>>(&v) {
114 cfg.thresholds.extend(map);
115 } else if let Ok(map) =
116 serde_json::from_str::<HashMap<String, Vec<ThresholdConfig>>>(&v)
117 {
118 for (metric, list) in map {
119 for (i, t) in list.into_iter().enumerate() {
120 let key = if i == 0 {
121 metric.clone()
122 } else {
123 format!("{metric}#{i}")
124 };
125 cfg.thresholds.insert(key, t);
126 }
127 }
128 } else {
129 tracing::warn!("K6_THRESHOLDS is not valid JSON — ignored");
130 }
131 }
132 if let Some(v) = env_str("K6_INSECURE_SKIP_TLS_VERIFY") {
133 if let Ok(b) = v.parse::<bool>() {
134 let mut tls = cfg.tls.take().unwrap_or_default();
135 tls.insecure_skip_verify = b;
136 cfg.tls = Some(tls);
137 }
138 }
139 if let Some(v) = env_str("K6_REPORTER") {
140 let mut out = cfg.output.take().unwrap_or_default();
141 out.reporters = v.split(',').map(|s| s.trim().to_string()).collect();
142 cfg.output = Some(out);
143 }
144 if let Some(v) = env_str("K6_OUTPUT") {
145 let mut out = cfg.output.take().unwrap_or_default();
146 out.output_file = Some(v);
147 cfg.output = Some(out);
148 }
149 if let Some(v) = env_str("K6_PROMETHEUS_URL") {
150 let mut out = cfg.output.take().unwrap_or_default();
151 out.prometheus_remote_write_url = Some(v);
152 cfg.output = Some(out);
153 }
154 if let Some(v) = env_str("K6_OTLP_ENDPOINT") {
155 let mut out = cfg.output.take().unwrap_or_default();
156 out.otlp_endpoint = Some(v);
157 cfg.output = Some(out);
158 }
159 cfg.execution_segment = env_str("K6_EXECUTION_SEGMENT");
160 cfg.execution_segment_sequence = env_str("K6_EXECUTION_SEGMENT_SEQUENCE");
161 if let Some(v) = env_str("K6_DISCARD_RESPONSE_BODIES") {
162 if let Ok(b) = v.parse::<bool>() {
163 let mut http = cfg.http.take().unwrap_or_default();
164 http.discard_response_bodies = b;
165 cfg.http = Some(http);
166 }
167 }
168
169 cfg
170 }
171}
172
173fn env_str(key: &str) -> Option<String> {
174 std::env::var(key).ok()
175}
176
177fn env_num<T: std::str::FromStr>(key: &str) -> Option<T> {
178 std::env::var(key).ok().and_then(|v| match v.parse() {
179 Ok(val) => Some(val),
180 Err(_) => {
181 tracing::debug!("K6_* env var '{key}' value '{v}' is not valid — ignored");
182 None
183 }
184 })
185}
186
187fn env_execution(
192 mode: Option<&str>,
193 vus: Option<u32>,
194 duration: Option<&str>,
195 iterations: Option<u64>,
196 stages: Option<&str>,
197) -> Option<ExecutionConfig> {
198 let think_time = ThinkTimeConfig::default();
199
200 if let Some(mode) = mode {
201 return Some(ExecutionConfig::from_mode(
204 mode,
205 vus,
206 duration.map(|s| s.to_string()),
207 iterations,
208 stages.map(|s| s.to_string()),
209 ));
210 }
211
212 if let Some(stages_str) = stages {
213 match serde_json::from_str::<Vec<Stage>>(stages_str) {
214 Ok(stage_list) if !stage_list.is_empty() => {
215 return Some(ExecutionConfig::RampingVus {
216 stages: stage_list,
217 start_vus: vus.unwrap_or(1),
218 graceful_ramp_down: Some("30s".to_string()),
219 graceful_stop: Some("30s".to_string()),
220 think_time,
221 });
222 }
223 Ok(_) => {
224 tracing::warn!("K6_STAGES parsed but is empty — ignoring");
225 }
226 Err(e) => {
227 tracing::warn!("K6_STAGES is malformed ({}): {}", stages_str, e);
228 }
229 }
230 }
231
232 if let Some(iterations) = iterations {
233 return Some(ExecutionConfig::SharedIterations {
234 iterations,
235 max_duration: duration.map(|s| s.to_string()),
236 vus: vus.unwrap_or(1),
237 graceful_stop: Some("30s".to_string()),
238 think_time,
239 });
240 }
241
242 if let (Some(vus), Some(duration)) = (vus, duration) {
243 return Some(ExecutionConfig::ConstantVus {
244 vus,
245 duration: duration.to_string(),
246 graceful_stop: Some("30s".to_string()),
247 think_time,
248 });
249 }
250
251 None
252}
253
254#[cfg(test)]
255mod env_tests {
256 use super::*;
257
258 #[test]
259 fn test_env_vus_duration_constant() {
260 let exec = env_execution(None, Some(5), Some("10s"), None, None);
261 match exec {
262 Some(ExecutionConfig::ConstantVus { vus, duration, .. }) => {
263 assert_eq!(vus, 5);
264 assert_eq!(duration, "10s");
265 }
266 other => panic!("expected ConstantVus, got {other:?}"),
267 }
268 }
269
270 #[test]
271 fn test_env_iterations_shared() {
272 let exec = env_execution(None, Some(3), None, Some(50), None);
273 match exec {
274 Some(ExecutionConfig::SharedIterations {
275 iterations, vus, ..
276 }) => {
277 assert_eq!(iterations, 50);
278 assert_eq!(vus, 3);
279 }
280 other => panic!("expected SharedIterations, got {other:?}"),
281 }
282 }
283
284 #[test]
285 fn test_env_stages_ramping() {
286 let exec = env_execution(
287 None,
288 Some(2),
289 None,
290 None,
291 Some(r#"[{"duration":"10s","target":20}]"#),
292 );
293 match exec {
294 Some(ExecutionConfig::RampingVus {
295 start_vus, stages, ..
296 }) => {
297 assert_eq!(start_vus, 2);
298 assert_eq!(stages[0].target, 20);
299 }
300 other => panic!("expected RampingVus, got {other:?}"),
301 }
302 }
303
304 #[test]
305 fn test_env_mode_wins() {
306 let exec = env_execution(
307 Some("shared-iterations"),
308 Some(4),
309 Some("10s"),
310 Some(99),
311 None,
312 );
313 match exec {
314 Some(ExecutionConfig::SharedIterations {
315 iterations, vus, ..
316 }) => {
317 assert_eq!(iterations, 99);
318 assert_eq!(vus, 4);
319 }
320 other => panic!("expected SharedIterations, got {other:?}"),
321 }
322 }
323
324 #[test]
325 fn test_env_nothing() {
326 assert!(env_execution(None, None, None, None, None).is_none());
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn test_load_config_file_roundtrip() {
336 let dir = std::env::temp_dir();
337 let path = dir.join("tropel_test_config.json");
338 std::fs::write(
339 &path,
340 r#"{
341 "input_type": "postman",
342 "thresholds": {"http_req_duration": {"expression": "http_req_duration.p95 < 500"}},
343 "tls": {"insecure_skip_verify": true}
344 }"#,
345 )
346 .unwrap();
347 let cfg = PartialConfig::load_from_file(&path).unwrap();
348 assert_eq!(cfg.input_type.as_deref(), Some("postman"));
349 assert_eq!(
350 cfg.thresholds.get("http_req_duration").unwrap().expression,
351 "http_req_duration.p95 < 500"
352 );
353 assert!(cfg.tls.unwrap().insecure_skip_verify);
354 std::fs::remove_file(&path).ok();
355 }
356
357 #[test]
358 fn test_config_file_missing_is_error() {
359 let err = PartialConfig::load_from_file(Path::new("/nonexistent/nope.json"));
360 assert!(err.is_err());
361 }
362}