1use apif_ast::GctfDocument;
8use apif_cfg_runtime as runtime;
9use apif_grpc_transport::{
10 CompressionMode, ProtoConfig, TlsConfig, WireProtocol, default_address_for,
11};
12use apif_utils::FileUtils;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use std::collections::HashMap;
16use std::path::Path;
17
18pub const REQUEST_CHANNEL_BUFFER: usize = 100;
22
23pub fn tls_env_defaults() -> HashMap<String, String> {
25 let mut defaults = HashMap::new();
26
27 if let Ok(value) = std::env::var("GRPCTESTIFY_TLS_CA_FILE")
28 && !value.trim().is_empty()
29 {
30 defaults.insert("ca_cert".to_string(), value);
31 }
32 if let Ok(value) = std::env::var("GRPCTESTIFY_TLS_CERT_FILE")
33 && !value.trim().is_empty()
34 {
35 defaults.insert("client_cert".to_string(), value);
36 }
37 if let Ok(value) = std::env::var("GRPCTESTIFY_TLS_KEY_FILE")
38 && !value.trim().is_empty()
39 {
40 defaults.insert("client_key".to_string(), value);
41 }
42 if let Ok(value) = std::env::var("GRPCTESTIFY_TLS_SERVER_NAME")
43 && !value.trim().is_empty()
44 {
45 defaults.insert("server_name".to_string(), value);
46 }
47
48 defaults
49}
50
51pub fn parse_bool_flag(value: &str) -> bool {
53 matches!(
54 value.trim().to_ascii_lowercase().as_str(),
55 "true" | "1" | "yes" | "on"
56 )
57}
58
59pub fn effective_address(
62 document: &GctfDocument,
63 protocol_override: Option<WireProtocol>,
64) -> String {
65 document
66 .get_address(std::env::var("GRPCTESTIFY_ADDRESS").ok().as_deref())
67 .unwrap_or_else(|| {
68 let proto = protocol_override.unwrap_or_else(|| {
69 document
70 .get_options()
71 .and_then(|o| o.get("protocol").cloned())
72 .map(|s| s.parse().unwrap_or_default())
73 .unwrap_or_default()
74 });
75 default_address_for(proto).to_string()
76 })
77}
78
79pub fn parse_compression_option(options: &HashMap<String, String>) -> Option<CompressionMode> {
81 options
82 .get("compression")
83 .map(|v| v.trim().to_ascii_lowercase())
84 .and_then(|v| match v.as_str() {
85 "gzip" => Some(CompressionMode::Gzip),
86 "none" | "" => Some(CompressionMode::None),
87 _ => None,
88 })
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum RuntimeOptionSource {
94 SectionAttribute,
95 FileOptions,
96 CliDefaults,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct RuntimeOptionWithSource<T> {
101 pub value: T,
102 pub source: RuntimeOptionSource,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct EffectiveRuntimeOptions {
107 pub timeout_seconds: RuntimeOptionWithSource<u64>,
108 pub retry: RuntimeOptionWithSource<u32>,
109 pub retry_delay_seconds: RuntimeOptionWithSource<f64>,
110 pub no_retry: RuntimeOptionWithSource<bool>,
111 pub compression: RuntimeOptionWithSource<String>,
112}
113
114#[derive(Debug, Clone, Copy)]
115pub struct CliRuntimeDefaults {
116 pub timeout_seconds: u64,
117 pub retry: u32,
118 pub retry_delay_seconds: f64,
119 pub no_retry: bool,
120}
121
122pub fn resolve_effective_runtime_options(
123 document: &GctfDocument,
124 cli: CliRuntimeDefaults,
125) -> Result<EffectiveRuntimeOptions, String> {
126 let options = document.get_options().unwrap_or_default();
127
128 let timeout_from_attr = document
129 .sections
130 .iter()
131 .filter_map(|s| s.get_timeout())
132 .find(|&v| v > 0);
133 let timeout_seconds = if let Some(v) = timeout_from_attr {
134 RuntimeOptionWithSource {
135 value: v,
136 source: RuntimeOptionSource::SectionAttribute,
137 }
138 } else if let Some(value) = options.get("timeout") {
139 match value.trim().parse::<u64>() {
140 Ok(v) if v > 0 => RuntimeOptionWithSource {
141 value: v,
142 source: RuntimeOptionSource::FileOptions,
143 },
144 _ => {
145 return Err(format!(
146 "OPTIONS.timeout must be a positive integer, got '{}'",
147 value
148 ));
149 }
150 }
151 } else {
152 RuntimeOptionWithSource {
153 value: cli.timeout_seconds,
154 source: RuntimeOptionSource::CliDefaults,
155 }
156 };
157
158 let no_retry_from_attr = document
159 .sections
160 .iter()
161 .filter_map(|s| {
162 s.get_attribute("no_retry")
163 .or_else(|| s.get_attribute("no-retry"))
164 })
165 .filter_map(|a| a.parse_bool())
166 .next();
167 let no_retry = if let Some(v) = no_retry_from_attr {
168 RuntimeOptionWithSource {
169 value: v,
170 source: RuntimeOptionSource::SectionAttribute,
171 }
172 } else if let Some(value) = options.get("no_retry").or_else(|| options.get("no-retry")) {
173 let parsed = match value.trim().to_ascii_lowercase().as_str() {
174 "true" | "1" | "yes" | "on" => Some(true),
175 "false" | "0" | "no" | "off" => Some(false),
176 _ => None,
177 };
178 match parsed {
179 Some(v) => RuntimeOptionWithSource {
180 value: v,
181 source: RuntimeOptionSource::FileOptions,
182 },
183 None => {
184 return Err(format!(
185 "OPTIONS.no_retry must be a boolean, got '{}'",
186 value
187 ));
188 }
189 }
190 } else {
191 RuntimeOptionWithSource {
192 value: cli.no_retry,
193 source: RuntimeOptionSource::CliDefaults,
194 }
195 };
196
197 let retry_from_attr = document
198 .sections
199 .iter()
200 .filter_map(|s| s.get_retry())
201 .next();
202 let retry = if let Some(v) = retry_from_attr {
203 RuntimeOptionWithSource {
204 value: v,
205 source: RuntimeOptionSource::SectionAttribute,
206 }
207 } else if let Some(value) = options.get("retry") {
208 match value.trim().parse::<u32>() {
209 Ok(v) => RuntimeOptionWithSource {
210 value: v,
211 source: RuntimeOptionSource::FileOptions,
212 },
213 Err(_) => {
214 return Err(format!(
215 "OPTIONS.retry must be a non-negative integer, got '{}'",
216 value
217 ));
218 }
219 }
220 } else {
221 RuntimeOptionWithSource {
222 value: cli.retry,
223 source: RuntimeOptionSource::CliDefaults,
224 }
225 };
226
227 let retry_delay_from_attr = document
228 .sections
229 .iter()
230 .filter_map(|s| {
231 s.get_attribute("retry_delay")
232 .or_else(|| s.get_attribute("retry-delay"))
233 })
234 .filter_map(|a| a.parse_f64())
235 .find(|v| *v >= 0.0);
236 let retry_delay_seconds = if let Some(v) = retry_delay_from_attr {
237 RuntimeOptionWithSource {
238 value: v,
239 source: RuntimeOptionSource::SectionAttribute,
240 }
241 } else if let Some(value) = options
242 .get("retry_delay")
243 .or_else(|| options.get("retry-delay"))
244 {
245 match value.trim().parse::<f64>() {
246 Ok(v) if v >= 0.0 => RuntimeOptionWithSource {
247 value: v,
248 source: RuntimeOptionSource::FileOptions,
249 },
250 _ => {
251 return Err(format!(
252 "OPTIONS.retry_delay must be a non-negative number, got '{}'",
253 value
254 ));
255 }
256 }
257 } else {
258 RuntimeOptionWithSource {
259 value: cli.retry_delay_seconds,
260 source: RuntimeOptionSource::CliDefaults,
261 }
262 };
263
264 let compression_from_attr = document
265 .sections
266 .iter()
267 .filter_map(|s| s.get_compression())
268 .next();
269 let compression = if let Some(v) = compression_from_attr {
270 RuntimeOptionWithSource {
271 value: v,
272 source: RuntimeOptionSource::SectionAttribute,
273 }
274 } else if options.contains_key("compression") {
275 RuntimeOptionWithSource {
276 value: match parse_compression_option(&options).unwrap_or(CompressionMode::None) {
277 CompressionMode::Gzip => "gzip".to_string(),
278 CompressionMode::None => "none".to_string(),
279 },
280 source: RuntimeOptionSource::FileOptions,
281 }
282 } else {
283 RuntimeOptionWithSource {
284 value: match CompressionMode::None {
285 CompressionMode::Gzip => "gzip".to_string(),
286 CompressionMode::None => "none".to_string(),
287 },
288 source: RuntimeOptionSource::CliDefaults,
289 }
290 };
291
292 Ok(EffectiveRuntimeOptions {
293 timeout_seconds,
294 retry,
295 retry_delay_seconds,
296 no_retry,
297 compression,
298 })
299}
300
301pub fn resolve_tls_path(value: &str, from_env: bool, document_path: &Path) -> String {
303 let path = Path::new(value);
304 if path.is_absolute() {
305 return path.to_string_lossy().to_string();
306 }
307
308 if from_env {
309 if runtime::supports(runtime::Capability::IsolatedFsIo)
310 && let Ok(cwd) = std::env::current_dir()
311 {
312 return cwd.join(path).to_string_lossy().to_string();
313 }
314 return path.to_string_lossy().to_string();
315 }
316
317 FileUtils::resolve_relative_path(document_path, value)
318 .to_string_lossy()
319 .to_string()
320}
321
322pub fn build_tls_config(document: &GctfDocument, document_path: &Path) -> Option<TlsConfig> {
324 let tls_defaults = tls_env_defaults();
325 let tls_section = document.get_tls_config();
326
327 let pick_tls_value = |keys: &[&str]| -> Option<(String, bool)> {
328 if let Some(section_map) = tls_section.as_ref() {
329 for key in keys {
330 if let Some(value) = section_map.get(*key) {
331 return Some((value.clone(), false));
332 }
333 }
334 }
335
336 for key in keys {
337 if let Some(value) = tls_defaults.get(*key) {
338 return Some((value.clone(), true));
339 }
340 }
341
342 None
343 };
344
345 let ca_cert_path = pick_tls_value(&["ca_cert", "ca_file"])
346 .map(|(v, from_env)| resolve_tls_path(&v, from_env, document_path));
347 let client_cert_path = pick_tls_value(&["client_cert", "cert", "cert_file"])
348 .map(|(v, from_env)| resolve_tls_path(&v, from_env, document_path));
349 let client_key_path = pick_tls_value(&["client_key", "key", "key_file"])
350 .map(|(v, from_env)| resolve_tls_path(&v, from_env, document_path));
351 let server_name = pick_tls_value(&["server_name"]).map(|(v, _)| v);
352 let insecure_skip_verify = tls_section
353 .as_ref()
354 .and_then(|m| m.get("insecure"))
355 .is_some_and(|s| parse_bool_flag(s));
356
357 if ca_cert_path.is_some()
358 || client_cert_path.is_some()
359 || client_key_path.is_some()
360 || server_name.is_some()
361 || insecure_skip_verify
362 {
363 Some(TlsConfig {
364 ca_cert_path,
365 client_cert_path,
366 client_key_path,
367 server_name,
368 insecure_skip_verify,
369 })
370 } else {
371 None
372 }
373}
374
375pub fn build_proto_config(document: &GctfDocument, document_path: &Path) -> Option<ProtoConfig> {
377 document.get_proto_config().map(|proto_map| {
378 let files = proto_map
379 .get("files")
380 .map(|s| {
381 s.split(',')
382 .map(|p| {
383 FileUtils::resolve_relative_path(document_path, p.trim())
384 .to_string_lossy()
385 .to_string()
386 })
387 .collect()
388 })
389 .unwrap_or_default();
390
391 let import_paths = proto_map
392 .get("import_paths")
393 .map(|s| {
394 s.split(',')
395 .map(|p| {
396 FileUtils::resolve_relative_path(document_path, p.trim())
397 .to_string_lossy()
398 .to_string()
399 })
400 .collect()
401 })
402 .unwrap_or_default();
403
404 let descriptor = proto_map.get("descriptor").map(|p| {
405 FileUtils::resolve_relative_path(document_path, p)
406 .to_string_lossy()
407 .to_string()
408 });
409
410 ProtoConfig {
411 files,
412 import_paths,
413 descriptor,
414 }
415 })
416}
417
418pub fn full_service_name(package: &str, service: &str) -> String {
420 if package.is_empty() {
421 service.to_string()
422 } else {
423 format!("{}.{}", package, service)
424 }
425}
426
427pub fn format_json_pretty(value: &Value) -> String {
429 serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
430}
431
432pub fn interpolate_variables(template: &str, variables: &HashMap<String, Value>) -> Option<String> {
436 let mut out = String::with_capacity(template.len());
437 let mut cursor = 0usize;
438 let mut changed = false;
439
440 while let Some(open_rel) = template[cursor..].find("{{") {
441 let open = cursor + open_rel;
442 out.push_str(&template[cursor..open]);
443
444 let after_open = open + 2;
445 if let Some(close_rel) = template[after_open..].find("}}") {
446 let close = after_open + close_rel;
447 let var_name = template[after_open..close].trim();
448
449 if let Some(var_value) = variables.get(var_name) {
450 if let Value::String(s) = var_value {
451 out.push_str(s);
452 } else {
453 out.push_str(&var_value.to_string());
454 }
455 changed = true;
456 } else {
457 out.push_str(&template[open..close + 2]);
458 }
459 cursor = close + 2;
460 } else {
461 out.push_str(&template[cursor..]);
462 break;
463 }
464 }
465
466 if cursor < template.len() {
467 out.push_str(&template[cursor..]);
468 }
469
470 if changed { Some(out) } else { None }
471}
472
473pub fn substitute_variables(value: &mut Value, variables: &HashMap<String, Value>) {
477 match value {
478 Value::String(s) => {
479 let original = s.clone();
480 if s.starts_with("{{") && s.ends_with("}}") {
481 let inner = s[2..s.len() - 2].trim();
482 if !inner.contains("{{")
483 && let Some(val) = variables.get(inner)
484 {
485 *value = val.clone();
486 return;
487 }
488 }
489 if let Some(replaced) = interpolate_variables(s, variables) {
490 *s = replaced;
491 }
492 if *s == original {
494 }
496 }
497 Value::Array(items) => {
498 for item in items {
499 substitute_variables(item, variables);
500 }
501 }
502 Value::Object(map) => {
503 for (_, val) in map.iter_mut() {
504 substitute_variables(val, variables);
505 }
506 }
507 _ => {}
508 }
509}
510
511pub fn metadata_map_to_hashmap(metadata: &tonic::metadata::MetadataMap) -> HashMap<String, String> {
513 let mut out = HashMap::new();
514 for kv in metadata.iter() {
515 if let tonic::metadata::KeyAndValueRef::Ascii(key, value) = kv {
516 if let Ok(v) = value.to_str() {
517 out.insert(key.to_string(), v.to_string());
518 }
519 } else if let tonic::metadata::KeyAndValueRef::Binary(key, value) = kv {
520 out.insert(key.to_string(), format!("{:?}", value));
521 }
522 }
523 out
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use apif_ast::{GctfAttribute, GctfDocument, Section, SectionContent, SectionType};
530
531 fn make_doc(sections: Vec<Section>) -> GctfDocument {
532 GctfDocument {
533 file_path: "test.gctf".to_string(),
534 sections,
535 metadata: Default::default(),
536 next_document: None,
537 }
538 }
539
540 fn make_section(section_type: SectionType, content: SectionContent) -> Section {
541 Section {
542 section_type,
543 content,
544 inline_options: Default::default(),
545 raw_content: String::new(),
546 start_line: 0,
547 end_line: 0,
548 attributes: Vec::new(),
549 }
550 }
551
552 fn make_section_with_attrs(
553 section_type: SectionType,
554 content: SectionContent,
555 attrs: Vec<GctfAttribute>,
556 ) -> Section {
557 let mut s = make_section(section_type, content);
558 s.attributes = attrs;
559 s
560 }
561
562 fn cli_defaults() -> CliRuntimeDefaults {
563 CliRuntimeDefaults {
564 timeout_seconds: 30,
565 retry: 0,
566 retry_delay_seconds: 1.0,
567 no_retry: false,
568 }
569 }
570
571 fn kv(map: &[(&str, &str)]) -> SectionContent {
572 SectionContent::KeyValues(
573 map.iter()
574 .map(|(k, v)| (k.to_string(), v.to_string()))
575 .collect(),
576 )
577 }
578
579 #[test]
580 fn test_resolve_defaults_only() {
581 let doc = make_doc(vec![
582 make_section(
583 SectionType::Endpoint,
584 SectionContent::Single("svc/Method".into()),
585 ),
586 make_section(SectionType::Request, SectionContent::Empty),
587 ]);
588 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
589
590 assert_eq!(result.timeout_seconds.value, 30);
591 assert_eq!(
592 result.timeout_seconds.source,
593 RuntimeOptionSource::CliDefaults
594 );
595 assert_eq!(result.retry.value, 0);
596 assert_eq!(result.retry.source, RuntimeOptionSource::CliDefaults);
597 assert_eq!(result.retry_delay_seconds.value, 1.0);
598 assert_eq!(
599 result.retry_delay_seconds.source,
600 RuntimeOptionSource::CliDefaults
601 );
602 assert!(!result.no_retry.value);
603 assert_eq!(result.no_retry.source, RuntimeOptionSource::CliDefaults);
604 }
605
606 #[test]
607 fn test_resolve_file_options_override_defaults() {
608 let doc = make_doc(vec![
609 make_section(
610 SectionType::Options,
611 kv(&[
612 ("timeout", "10"),
613 ("retry", "3"),
614 ("retry_delay", "0.5"),
615 ("no_retry", "true"),
616 ]),
617 ),
618 make_section(
619 SectionType::Endpoint,
620 SectionContent::Single("svc/Method".into()),
621 ),
622 make_section(SectionType::Request, SectionContent::Empty),
623 ]);
624 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
625
626 assert_eq!(result.timeout_seconds.value, 10);
627 assert_eq!(
628 result.timeout_seconds.source,
629 RuntimeOptionSource::FileOptions
630 );
631 assert_eq!(result.retry.value, 3);
632 assert_eq!(result.retry.source, RuntimeOptionSource::FileOptions);
633 assert_eq!(result.retry_delay_seconds.value, 0.5);
634 assert_eq!(
635 result.retry_delay_seconds.source,
636 RuntimeOptionSource::FileOptions
637 );
638 assert!(result.no_retry.value);
639 assert_eq!(result.no_retry.source, RuntimeOptionSource::FileOptions);
640 }
641
642 #[test]
643 fn test_resolve_section_attribute_overrides_file_options() {
644 let doc = make_doc(vec![
645 make_section(
646 SectionType::Options,
647 kv(&[("timeout", "10"), ("retry", "3"), ("retry_delay", "0.5")]),
648 ),
649 make_section_with_attrs(
650 SectionType::Request,
651 SectionContent::Empty,
652 vec![
653 GctfAttribute::new("timeout", "5"),
654 GctfAttribute::new("retry", "7"),
655 GctfAttribute::new("retry_delay", "2.0"),
656 GctfAttribute::flag("no_retry"),
657 ],
658 ),
659 ]);
660 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
661
662 assert_eq!(result.timeout_seconds.value, 5);
663 assert_eq!(
664 result.timeout_seconds.source,
665 RuntimeOptionSource::SectionAttribute
666 );
667 assert_eq!(result.retry.value, 7);
668 assert_eq!(result.retry.source, RuntimeOptionSource::SectionAttribute);
669 assert_eq!(result.retry_delay_seconds.value, 2.0);
670 assert_eq!(
671 result.retry_delay_seconds.source,
672 RuntimeOptionSource::SectionAttribute
673 );
674 assert!(result.no_retry.value);
675 assert_eq!(
676 result.no_retry.source,
677 RuntimeOptionSource::SectionAttribute
678 );
679 }
680
681 #[test]
682 fn test_resolve_attribute_overrides_options_only_for_present_fields() {
683 let doc = make_doc(vec![
684 make_section(
685 SectionType::Options,
686 kv(&[("timeout", "10"), ("retry", "3")]),
687 ),
688 make_section_with_attrs(
689 SectionType::Request,
690 SectionContent::Empty,
691 vec![GctfAttribute::new("retry", "5")],
692 ),
693 ]);
694 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
695
696 assert_eq!(result.timeout_seconds.value, 10);
697 assert_eq!(
698 result.timeout_seconds.source,
699 RuntimeOptionSource::FileOptions
700 );
701 assert_eq!(result.retry.value, 5);
702 assert_eq!(result.retry.source, RuntimeOptionSource::SectionAttribute);
703 assert_eq!(result.retry_delay_seconds.value, 1.0);
704 assert_eq!(
705 result.retry_delay_seconds.source,
706 RuntimeOptionSource::CliDefaults
707 );
708 }
709
710 #[test]
711 fn test_resolve_kebab_alias_in_options() {
712 let doc = make_doc(vec![
713 make_section(
714 SectionType::Options,
715 kv(&[("retry-delay", "0.2"), ("no-retry", "true")]),
716 ),
717 make_section(
718 SectionType::Endpoint,
719 SectionContent::Single("svc/Method".into()),
720 ),
721 make_section(SectionType::Request, SectionContent::Empty),
722 ]);
723 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
724
725 assert_eq!(result.retry_delay_seconds.value, 0.2);
726 assert_eq!(
727 result.retry_delay_seconds.source,
728 RuntimeOptionSource::FileOptions
729 );
730 assert!(result.no_retry.value);
731 assert_eq!(result.no_retry.source, RuntimeOptionSource::FileOptions);
732 }
733
734 #[test]
735 fn test_resolve_kebab_alias_in_attributes() {
736 let doc = make_doc(vec![make_section_with_attrs(
737 SectionType::Request,
738 SectionContent::Empty,
739 vec![
740 GctfAttribute::new("retry-delay", "0.3"),
741 GctfAttribute::new("no-retry", "true"),
742 ],
743 )]);
744 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
745
746 assert_eq!(result.retry_delay_seconds.value, 0.3);
747 assert_eq!(
748 result.retry_delay_seconds.source,
749 RuntimeOptionSource::SectionAttribute
750 );
751 assert!(result.no_retry.value);
752 assert_eq!(
753 result.no_retry.source,
754 RuntimeOptionSource::SectionAttribute
755 );
756 }
757
758 #[test]
759 fn test_resolve_error_invalid_timeout() {
760 let doc = make_doc(vec![
761 make_section(SectionType::Options, kv(&[("timeout", "abc")])),
762 make_section(
763 SectionType::Endpoint,
764 SectionContent::Single("svc/Method".into()),
765 ),
766 ]);
767 let result = resolve_effective_runtime_options(&doc, cli_defaults());
768 assert!(result.is_err());
769 assert!(result.unwrap_err().contains("timeout"));
770 }
771
772 #[test]
773 fn test_resolve_error_zero_timeout() {
774 let doc = make_doc(vec![
775 make_section(SectionType::Options, kv(&[("timeout", "0")])),
776 make_section(
777 SectionType::Endpoint,
778 SectionContent::Single("svc/Method".into()),
779 ),
780 ]);
781 let result = resolve_effective_runtime_options(&doc, cli_defaults());
782 assert!(result.is_err());
783 }
784
785 #[test]
786 fn test_resolve_error_invalid_retry() {
787 let doc = make_doc(vec![
788 make_section(SectionType::Options, kv(&[("retry", "abc")])),
789 make_section(
790 SectionType::Endpoint,
791 SectionContent::Single("svc/Method".into()),
792 ),
793 ]);
794 let result = resolve_effective_runtime_options(&doc, cli_defaults());
795 assert!(result.is_err());
796 assert!(result.unwrap_err().contains("retry"));
797 }
798
799 #[test]
800 fn test_resolve_error_invalid_retry_delay() {
801 let doc = make_doc(vec![
802 make_section(SectionType::Options, kv(&[("retry_delay", "abc")])),
803 make_section(
804 SectionType::Endpoint,
805 SectionContent::Single("svc/Method".into()),
806 ),
807 ]);
808 let result = resolve_effective_runtime_options(&doc, cli_defaults());
809 assert!(result.is_err());
810 assert!(result.unwrap_err().contains("retry_delay"));
811 }
812
813 #[test]
814 fn test_resolve_error_negative_retry_delay() {
815 let doc = make_doc(vec![
816 make_section(SectionType::Options, kv(&[("retry_delay", "-1.0")])),
817 make_section(
818 SectionType::Endpoint,
819 SectionContent::Single("svc/Method".into()),
820 ),
821 ]);
822 let result = resolve_effective_runtime_options(&doc, cli_defaults());
823 assert!(result.is_err());
824 }
825
826 #[test]
827 fn test_resolve_error_invalid_no_retry() {
828 let doc = make_doc(vec![
829 make_section(SectionType::Options, kv(&[("no_retry", "maybe")])),
830 make_section(
831 SectionType::Endpoint,
832 SectionContent::Single("svc/Method".into()),
833 ),
834 ]);
835 let result = resolve_effective_runtime_options(&doc, cli_defaults());
836 assert!(result.is_err());
837 assert!(result.unwrap_err().contains("no_retry"));
838 }
839
840 #[test]
841 fn test_resolve_zero_timeout_attribute_ignored() {
842 let doc = make_doc(vec![make_section_with_attrs(
843 SectionType::Request,
844 SectionContent::Empty,
845 vec![GctfAttribute::new("timeout", "0")],
846 )]);
847 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
848
849 assert_eq!(result.timeout_seconds.value, 30);
850 assert_eq!(
851 result.timeout_seconds.source,
852 RuntimeOptionSource::CliDefaults
853 );
854 }
855
856 #[test]
857 fn test_resolve_negative_retry_delay_attribute_ignored() {
858 let doc = make_doc(vec![make_section_with_attrs(
859 SectionType::Request,
860 SectionContent::Empty,
861 vec![GctfAttribute::new("retry_delay", "-0.5")],
862 )]);
863 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
864
865 assert_eq!(result.retry_delay_seconds.value, 1.0);
866 assert_eq!(
867 result.retry_delay_seconds.source,
868 RuntimeOptionSource::CliDefaults
869 );
870 }
871
872 #[test]
873 fn test_resolve_compression_from_options() {
874 let doc = make_doc(vec![
875 make_section(SectionType::Options, kv(&[("compression", "gzip")])),
876 make_section(
877 SectionType::Endpoint,
878 SectionContent::Single("svc/Method".into()),
879 ),
880 ]);
881 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
882
883 assert_eq!(result.compression.value, "gzip");
884 assert_eq!(result.compression.source, RuntimeOptionSource::FileOptions);
885 }
886
887 #[test]
888 fn test_resolve_compression_defaults() {
889 let doc = make_doc(vec![make_section(
890 SectionType::Endpoint,
891 SectionContent::Single("svc/Method".into()),
892 )]);
893 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
894
895 assert_eq!(result.compression.source, RuntimeOptionSource::CliDefaults);
896 }
897
898 #[test]
899 fn test_resolve_json_serialization() {
900 let doc = make_doc(vec![
901 make_section(
902 SectionType::Options,
903 kv(&[("timeout", "10"), ("retry", "3")]),
904 ),
905 make_section(
906 SectionType::Endpoint,
907 SectionContent::Single("svc/Method".into()),
908 ),
909 ]);
910 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
911
912 let json = serde_json::to_value(&result).unwrap();
913 let obj = json.as_object().unwrap();
914 assert!(obj.contains_key("timeout_seconds"));
915 assert!(obj.contains_key("retry"));
916 assert!(obj.contains_key("retry_delay_seconds"));
917 assert!(obj.contains_key("no_retry"));
918 assert!(obj.contains_key("compression"));
919
920 let ts = &obj["timeout_seconds"];
921 assert_eq!(ts["value"], 10);
922 assert_eq!(ts["source"], "file_options");
923 }
924
925 #[test]
926 fn test_runtime_option_source_serde_roundtrip() {
927 let sources = vec![
928 RuntimeOptionSource::SectionAttribute,
929 RuntimeOptionSource::FileOptions,
930 RuntimeOptionSource::CliDefaults,
931 ];
932 for source in sources {
933 let json = serde_json::to_string(&source).unwrap();
934 let back: RuntimeOptionSource = serde_json::from_str(&json).unwrap();
935 assert_eq!(source, back);
936 }
937 }
938
939 #[test]
940 fn test_effective_runtime_options_clone_roundtrip() {
941 let doc = make_doc(vec![
942 make_section(SectionType::Options, kv(&[("timeout", "10")])),
943 make_section(
944 SectionType::Endpoint,
945 SectionContent::Single("svc/Method".into()),
946 ),
947 ]);
948 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
949 let cloned = result.clone();
950 assert_eq!(cloned.timeout_seconds.value, 10);
951 assert_eq!(
952 cloned.timeout_seconds.source,
953 RuntimeOptionSource::FileOptions
954 );
955 }
956
957 #[test]
958 fn test_resolve_compression_from_section_attribute() {
959 let doc = make_doc(vec![make_section_with_attrs(
960 SectionType::Request,
961 SectionContent::Empty,
962 vec![GctfAttribute::new("compression", "gzip")],
963 )]);
964 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
965
966 assert_eq!(result.compression.value, "gzip");
967 assert_eq!(
968 result.compression.source,
969 RuntimeOptionSource::SectionAttribute
970 );
971 }
972
973 #[test]
974 fn test_resolve_compression_attribute_overrides_file_options() {
975 let doc = make_doc(vec![
976 make_section(SectionType::Options, kv(&[("compression", "none")])),
977 make_section_with_attrs(
978 SectionType::Request,
979 SectionContent::Empty,
980 vec![GctfAttribute::new("compression", "gzip")],
981 ),
982 ]);
983 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
984
985 assert_eq!(result.compression.value, "gzip");
986 assert_eq!(
987 result.compression.source,
988 RuntimeOptionSource::SectionAttribute
989 );
990 }
991
992 #[test]
993 fn test_resolve_compression_attribute_none_value() {
994 let doc = make_doc(vec![make_section_with_attrs(
995 SectionType::Request,
996 SectionContent::Empty,
997 vec![GctfAttribute::new("compression", "none")],
998 )]);
999 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1000
1001 assert_eq!(result.compression.value, "none");
1002 assert_eq!(
1003 result.compression.source,
1004 RuntimeOptionSource::SectionAttribute
1005 );
1006 }
1007
1008 #[test]
1009 fn test_resolve_compression_invalid_attribute_value_falls_back() {
1010 let doc = make_doc(vec![
1011 make_section(SectionType::Options, kv(&[("compression", "gzip")])),
1012 make_section_with_attrs(
1013 SectionType::Request,
1014 SectionContent::Empty,
1015 vec![GctfAttribute::new("compression", "invalid")],
1016 ),
1017 ]);
1018 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1019
1020 assert_eq!(result.compression.value, "gzip");
1021 assert_eq!(result.compression.source, RuntimeOptionSource::FileOptions);
1022 }
1023}