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
91pub fn resolve_compression(
95 document: &GctfDocument,
96 options: &HashMap<String, String>,
97 env_default: CompressionMode,
98) -> Result<CompressionMode, String> {
99 if let Some(attr) = document
103 .sections
104 .iter()
105 .filter_map(|s| s.get_compression())
106 .next()
107 {
108 return Ok(if attr == "gzip" {
109 CompressionMode::Gzip
110 } else {
111 CompressionMode::None
112 });
113 }
114
115 if let Some(raw) = options.get("compression") {
116 return parse_compression_option(options).ok_or_else(|| {
117 format!(
118 "OPTIONS.compression must be 'gzip' or 'none', got '{}'",
119 raw
120 )
121 });
122 }
123
124 Ok(env_default)
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum RuntimeOptionSource {
130 SectionAttribute,
131 FileOptions,
132 CliDefaults,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct RuntimeOptionWithSource<T> {
137 pub value: T,
138 pub source: RuntimeOptionSource,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct EffectiveRuntimeOptions {
143 pub timeout_seconds: RuntimeOptionWithSource<u64>,
144 pub retry: RuntimeOptionWithSource<u32>,
145 pub retry_delay_seconds: RuntimeOptionWithSource<f64>,
146 pub no_retry: RuntimeOptionWithSource<bool>,
147 pub compression: RuntimeOptionWithSource<String>,
148}
149
150#[derive(Debug, Clone, Copy)]
151pub struct CliRuntimeDefaults {
152 pub timeout_seconds: u64,
153 pub retry: u32,
154 pub retry_delay_seconds: f64,
155 pub no_retry: bool,
156}
157
158pub fn resolve_effective_runtime_options(
159 document: &GctfDocument,
160 cli: CliRuntimeDefaults,
161) -> Result<EffectiveRuntimeOptions, String> {
162 let options = document.get_options().unwrap_or_default();
163
164 let timeout_from_attr = document
165 .sections
166 .iter()
167 .filter_map(|s| s.get_timeout())
168 .find(|&v| v > 0);
169 let timeout_seconds = if let Some(v) = timeout_from_attr {
170 RuntimeOptionWithSource {
171 value: v,
172 source: RuntimeOptionSource::SectionAttribute,
173 }
174 } else if let Some(value) = options.get("timeout") {
175 match value.trim().parse::<u64>() {
176 Ok(v) if v > 0 => RuntimeOptionWithSource {
177 value: v,
178 source: RuntimeOptionSource::FileOptions,
179 },
180 _ => {
181 return Err(format!(
182 "OPTIONS.timeout must be a positive integer, got '{}'",
183 value
184 ));
185 }
186 }
187 } else {
188 RuntimeOptionWithSource {
189 value: cli.timeout_seconds,
190 source: RuntimeOptionSource::CliDefaults,
191 }
192 };
193
194 let no_retry_from_attr = document
195 .sections
196 .iter()
197 .filter_map(|s| {
198 s.get_attribute("no_retry")
199 .or_else(|| s.get_attribute("no-retry"))
200 })
201 .filter_map(|a| a.parse_bool())
202 .next();
203 let no_retry = if let Some(v) = no_retry_from_attr {
204 RuntimeOptionWithSource {
205 value: v,
206 source: RuntimeOptionSource::SectionAttribute,
207 }
208 } else if let Some(value) = options.get("no_retry").or_else(|| options.get("no-retry")) {
209 let parsed = match value.trim().to_ascii_lowercase().as_str() {
210 "true" | "1" | "yes" | "on" => Some(true),
211 "false" | "0" | "no" | "off" => Some(false),
212 _ => None,
213 };
214 match parsed {
215 Some(v) => RuntimeOptionWithSource {
216 value: v,
217 source: RuntimeOptionSource::FileOptions,
218 },
219 None => {
220 return Err(format!(
221 "OPTIONS.no_retry must be a boolean, got '{}'",
222 value
223 ));
224 }
225 }
226 } else {
227 RuntimeOptionWithSource {
228 value: cli.no_retry,
229 source: RuntimeOptionSource::CliDefaults,
230 }
231 };
232
233 let retry_from_attr = document
234 .sections
235 .iter()
236 .filter_map(|s| s.get_retry())
237 .next();
238 let retry = if let Some(v) = retry_from_attr {
239 RuntimeOptionWithSource {
240 value: v,
241 source: RuntimeOptionSource::SectionAttribute,
242 }
243 } else if let Some(value) = options.get("retry") {
244 match value.trim().parse::<u32>() {
245 Ok(v) => RuntimeOptionWithSource {
246 value: v,
247 source: RuntimeOptionSource::FileOptions,
248 },
249 Err(_) => {
250 return Err(format!(
251 "OPTIONS.retry must be a non-negative integer, got '{}'",
252 value
253 ));
254 }
255 }
256 } else {
257 RuntimeOptionWithSource {
258 value: cli.retry,
259 source: RuntimeOptionSource::CliDefaults,
260 }
261 };
262
263 let retry_delay_from_attr = document
264 .sections
265 .iter()
266 .filter_map(|s| {
267 s.get_attribute("retry_delay")
268 .or_else(|| s.get_attribute("retry-delay"))
269 })
270 .filter_map(|a| a.parse_f64())
271 .find(|v| *v >= 0.0);
272 let retry_delay_seconds = if let Some(v) = retry_delay_from_attr {
273 RuntimeOptionWithSource {
274 value: v,
275 source: RuntimeOptionSource::SectionAttribute,
276 }
277 } else if let Some(value) = options
278 .get("retry_delay")
279 .or_else(|| options.get("retry-delay"))
280 {
281 match value.trim().parse::<f64>() {
282 Ok(v) if v >= 0.0 => RuntimeOptionWithSource {
283 value: v,
284 source: RuntimeOptionSource::FileOptions,
285 },
286 _ => {
287 return Err(format!(
288 "OPTIONS.retry_delay must be a non-negative number, got '{}'",
289 value
290 ));
291 }
292 }
293 } else {
294 RuntimeOptionWithSource {
295 value: cli.retry_delay_seconds,
296 source: RuntimeOptionSource::CliDefaults,
297 }
298 };
299
300 let compression_from_attr = document
305 .sections
306 .iter()
307 .filter_map(|s| s.get_compression())
308 .next();
309 let compression = if let Some(v) = compression_from_attr {
310 RuntimeOptionWithSource {
311 value: v,
312 source: RuntimeOptionSource::SectionAttribute,
313 }
314 } else if let Some(raw) = options.get("compression") {
315 let mode = parse_compression_option(&options).ok_or_else(|| {
318 format!(
319 "OPTIONS.compression must be 'gzip' or 'none', got '{}'",
320 raw
321 )
322 })?;
323 RuntimeOptionWithSource {
324 value: match mode {
325 CompressionMode::Gzip => "gzip".to_string(),
326 CompressionMode::None => "none".to_string(),
327 },
328 source: RuntimeOptionSource::FileOptions,
329 }
330 } else {
331 RuntimeOptionWithSource {
332 value: match CompressionMode::None {
333 CompressionMode::Gzip => "gzip".to_string(),
334 CompressionMode::None => "none".to_string(),
335 },
336 source: RuntimeOptionSource::CliDefaults,
337 }
338 };
339
340 Ok(EffectiveRuntimeOptions {
341 timeout_seconds,
342 retry,
343 retry_delay_seconds,
344 no_retry,
345 compression,
346 })
347}
348
349pub fn resolve_tls_path(value: &str, from_env: bool, document_path: &Path) -> String {
351 let path = Path::new(value);
352 if path.is_absolute() {
353 return path.to_string_lossy().to_string();
354 }
355
356 if from_env {
357 if runtime::supports(runtime::Capability::IsolatedFsIo)
358 && let Ok(cwd) = std::env::current_dir()
359 {
360 return cwd.join(path).to_string_lossy().to_string();
361 }
362 return path.to_string_lossy().to_string();
363 }
364
365 FileUtils::resolve_relative_path(document_path, value)
366 .to_string_lossy()
367 .to_string()
368}
369
370pub fn build_tls_config(document: &GctfDocument, document_path: &Path) -> Option<TlsConfig> {
372 let tls_defaults = tls_env_defaults();
373 let tls_section = document.get_tls_config();
374
375 let pick_tls_value = |keys: &[&str]| -> Option<(String, bool)> {
376 if let Some(section_map) = tls_section.as_ref() {
377 for key in keys {
378 if let Some(value) = section_map.get(*key) {
379 return Some((value.clone(), false));
380 }
381 }
382 }
383
384 for key in keys {
385 if let Some(value) = tls_defaults.get(*key) {
386 return Some((value.clone(), true));
387 }
388 }
389
390 None
391 };
392
393 let ca_cert_path = pick_tls_value(&["ca_cert", "ca_file"])
394 .map(|(v, from_env)| resolve_tls_path(&v, from_env, document_path));
395 let client_cert_path = pick_tls_value(&["client_cert", "cert", "cert_file"])
396 .map(|(v, from_env)| resolve_tls_path(&v, from_env, document_path));
397 let client_key_path = pick_tls_value(&["client_key", "key", "key_file"])
398 .map(|(v, from_env)| resolve_tls_path(&v, from_env, document_path));
399 let server_name = pick_tls_value(&["server_name"]).map(|(v, _)| v);
400 let insecure_skip_verify = tls_section
401 .as_ref()
402 .and_then(|m| m.get("insecure"))
403 .is_some_and(|s| parse_bool_flag(s));
404
405 if ca_cert_path.is_some()
406 || client_cert_path.is_some()
407 || client_key_path.is_some()
408 || server_name.is_some()
409 || insecure_skip_verify
410 {
411 Some(TlsConfig {
412 ca_cert_path,
413 client_cert_path,
414 client_key_path,
415 server_name,
416 insecure_skip_verify,
417 })
418 } else {
419 None
420 }
421}
422
423pub fn build_proto_config(document: &GctfDocument, document_path: &Path) -> Option<ProtoConfig> {
425 document.get_proto_config().map(|proto_map| {
426 let files = proto_map
427 .get("files")
428 .map(|s| {
429 s.split(',')
430 .map(|p| {
431 FileUtils::resolve_relative_path(document_path, p.trim())
432 .to_string_lossy()
433 .to_string()
434 })
435 .collect()
436 })
437 .unwrap_or_default();
438
439 let import_paths = proto_map
440 .get("import_paths")
441 .map(|s| {
442 s.split(',')
443 .map(|p| {
444 FileUtils::resolve_relative_path(document_path, p.trim())
445 .to_string_lossy()
446 .to_string()
447 })
448 .collect()
449 })
450 .unwrap_or_default();
451
452 let descriptor = proto_map.get("descriptor").map(|p| {
453 FileUtils::resolve_relative_path(document_path, p)
454 .to_string_lossy()
455 .to_string()
456 });
457
458 ProtoConfig {
459 files,
460 import_paths,
461 descriptor,
462 }
463 })
464}
465
466pub fn full_service_name(package: &str, service: &str) -> String {
468 if package.is_empty() {
469 service.to_string()
470 } else {
471 format!("{}.{}", package, service)
472 }
473}
474
475pub fn format_json_pretty(value: &Value) -> String {
477 serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
478}
479
480pub fn interpolate_variables(template: &str, variables: &HashMap<String, Value>) -> Option<String> {
484 let mut out = String::with_capacity(template.len());
485 let mut cursor = 0usize;
486 let mut changed = false;
487
488 while let Some(open_rel) = template[cursor..].find("{{") {
489 let open = cursor + open_rel;
490 out.push_str(&template[cursor..open]);
491
492 let after_open = open + 2;
493 if let Some(close_rel) = template[after_open..].find("}}") {
494 let close = after_open + close_rel;
495 let var_name = template[after_open..close].trim();
496
497 if let Some(var_value) = variables.get(var_name) {
498 if let Value::String(s) = var_value {
499 out.push_str(s);
500 } else {
501 out.push_str(&var_value.to_string());
502 }
503 changed = true;
504 } else {
505 out.push_str(&template[open..close + 2]);
506 }
507 cursor = close + 2;
508 } else {
509 out.push_str(&template[cursor..]);
510 break;
511 }
512 }
513
514 if cursor < template.len() {
515 out.push_str(&template[cursor..]);
516 }
517
518 if changed { Some(out) } else { None }
519}
520
521pub fn substitute_variables(value: &mut Value, variables: &HashMap<String, Value>) {
525 match value {
526 Value::String(s) => {
527 if s.starts_with("{{") && s.ends_with("}}") {
528 let inner = s[2..s.len() - 2].trim();
529 if !inner.contains("{{")
530 && let Some(val) = variables.get(inner)
531 {
532 *value = val.clone();
533 return;
534 }
535 }
536 if let Some(replaced) = interpolate_variables(s, variables) {
537 *s = replaced;
538 }
539 }
540 Value::Array(items) => {
541 for item in items {
542 substitute_variables(item, variables);
543 }
544 }
545 Value::Object(map) => {
546 for (_, val) in map.iter_mut() {
547 substitute_variables(val, variables);
548 }
549 }
550 _ => {}
551 }
552}
553
554fn is_variable_placeholder(body: &str) -> bool {
560 let mut chars = body.chars();
561 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
562 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
563}
564
565pub fn find_unresolved_placeholders(
570 text: &str,
571 variables: &HashMap<String, Value>,
572 out: &mut Vec<String>,
573) {
574 let mut cursor = 0usize;
575 while let Some(open_rel) = text[cursor..].find("{{") {
576 let after_open = cursor + open_rel + 2;
577 let Some(close_rel) = text[after_open..].find("}}") else {
578 break;
579 };
580 let close = after_open + close_rel;
581 let name = text[after_open..close].trim();
582 if is_variable_placeholder(name)
583 && !variables.contains_key(name)
584 && !out.iter().any(|n| n == name)
585 {
586 out.push(name.to_string());
587 }
588 cursor = close + 2;
589 }
590}
591
592pub fn collect_unresolved_placeholders(
595 value: &Value,
596 variables: &HashMap<String, Value>,
597 out: &mut Vec<String>,
598) {
599 match value {
600 Value::String(s) => find_unresolved_placeholders(s, variables, out),
601 Value::Array(items) => {
602 for item in items {
603 collect_unresolved_placeholders(item, variables, out);
604 }
605 }
606 Value::Object(map) => {
607 for val in map.values() {
608 collect_unresolved_placeholders(val, variables, out);
609 }
610 }
611 _ => {}
612 }
613}
614
615pub fn format_unresolved_placeholders(names: &[String]) -> String {
617 names
618 .iter()
619 .map(|n| format!("{{{{{n}}}}}"))
620 .collect::<Vec<_>>()
621 .join(", ")
622}
623
624pub fn metadata_map_to_hashmap(metadata: &tonic::metadata::MetadataMap) -> HashMap<String, String> {
626 let mut out = HashMap::new();
627 for kv in metadata.iter() {
628 if let tonic::metadata::KeyAndValueRef::Ascii(key, value) = kv {
629 if let Ok(v) = value.to_str() {
630 out.insert(key.to_string(), v.to_string());
631 }
632 } else if let tonic::metadata::KeyAndValueRef::Binary(key, value) = kv {
633 out.insert(key.to_string(), format!("{:?}", value));
634 }
635 }
636 out
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use apif_ast::{GctfAttribute, GctfDocument, Section, SectionContent, SectionType};
643
644 fn make_doc(sections: Vec<Section>) -> GctfDocument {
645 GctfDocument {
646 file_path: "test.gctf".to_string(),
647 sections,
648 metadata: Default::default(),
649 next_document: None,
650 }
651 }
652
653 fn make_section(section_type: SectionType, content: SectionContent) -> Section {
654 Section {
655 section_type,
656 content,
657 inline_options: Default::default(),
658 raw_content: String::new(),
659 start_line: 0,
660 end_line: 0,
661 attributes: Vec::new(),
662 }
663 }
664
665 fn make_section_with_attrs(
666 section_type: SectionType,
667 content: SectionContent,
668 attrs: Vec<GctfAttribute>,
669 ) -> Section {
670 let mut s = make_section(section_type, content);
671 s.attributes = attrs;
672 s
673 }
674
675 fn cli_defaults() -> CliRuntimeDefaults {
676 CliRuntimeDefaults {
677 timeout_seconds: 30,
678 retry: 0,
679 retry_delay_seconds: 1.0,
680 no_retry: false,
681 }
682 }
683
684 fn kv(map: &[(&str, &str)]) -> SectionContent {
685 SectionContent::KeyValues(
686 map.iter()
687 .map(|(k, v)| (k.to_string(), v.to_string()))
688 .collect(),
689 )
690 }
691
692 #[test]
693 fn test_resolve_defaults_only() {
694 let doc = make_doc(vec![
695 make_section(
696 SectionType::Endpoint,
697 SectionContent::Single("svc/Method".into()),
698 ),
699 make_section(SectionType::Request, SectionContent::Empty),
700 ]);
701 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
702
703 assert_eq!(result.timeout_seconds.value, 30);
704 assert_eq!(
705 result.timeout_seconds.source,
706 RuntimeOptionSource::CliDefaults
707 );
708 assert_eq!(result.retry.value, 0);
709 assert_eq!(result.retry.source, RuntimeOptionSource::CliDefaults);
710 assert_eq!(result.retry_delay_seconds.value, 1.0);
711 assert_eq!(
712 result.retry_delay_seconds.source,
713 RuntimeOptionSource::CliDefaults
714 );
715 assert!(!result.no_retry.value);
716 assert_eq!(result.no_retry.source, RuntimeOptionSource::CliDefaults);
717 }
718
719 #[test]
720 fn test_resolve_file_options_override_defaults() {
721 let doc = make_doc(vec![
722 make_section(
723 SectionType::Options,
724 kv(&[
725 ("timeout", "10"),
726 ("retry", "3"),
727 ("retry_delay", "0.5"),
728 ("no_retry", "true"),
729 ]),
730 ),
731 make_section(
732 SectionType::Endpoint,
733 SectionContent::Single("svc/Method".into()),
734 ),
735 make_section(SectionType::Request, SectionContent::Empty),
736 ]);
737 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
738
739 assert_eq!(result.timeout_seconds.value, 10);
740 assert_eq!(
741 result.timeout_seconds.source,
742 RuntimeOptionSource::FileOptions
743 );
744 assert_eq!(result.retry.value, 3);
745 assert_eq!(result.retry.source, RuntimeOptionSource::FileOptions);
746 assert_eq!(result.retry_delay_seconds.value, 0.5);
747 assert_eq!(
748 result.retry_delay_seconds.source,
749 RuntimeOptionSource::FileOptions
750 );
751 assert!(result.no_retry.value);
752 assert_eq!(result.no_retry.source, RuntimeOptionSource::FileOptions);
753 }
754
755 #[test]
756 fn test_resolve_section_attribute_overrides_file_options() {
757 let doc = make_doc(vec![
758 make_section(
759 SectionType::Options,
760 kv(&[("timeout", "10"), ("retry", "3"), ("retry_delay", "0.5")]),
761 ),
762 make_section_with_attrs(
763 SectionType::Request,
764 SectionContent::Empty,
765 vec![
766 GctfAttribute::new("timeout", "5"),
767 GctfAttribute::new("retry", "7"),
768 GctfAttribute::new("retry_delay", "2.0"),
769 GctfAttribute::flag("no_retry"),
770 ],
771 ),
772 ]);
773 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
774
775 assert_eq!(result.timeout_seconds.value, 5);
776 assert_eq!(
777 result.timeout_seconds.source,
778 RuntimeOptionSource::SectionAttribute
779 );
780 assert_eq!(result.retry.value, 7);
781 assert_eq!(result.retry.source, RuntimeOptionSource::SectionAttribute);
782 assert_eq!(result.retry_delay_seconds.value, 2.0);
783 assert_eq!(
784 result.retry_delay_seconds.source,
785 RuntimeOptionSource::SectionAttribute
786 );
787 assert!(result.no_retry.value);
788 assert_eq!(
789 result.no_retry.source,
790 RuntimeOptionSource::SectionAttribute
791 );
792 }
793
794 #[test]
795 fn test_resolve_attribute_overrides_options_only_for_present_fields() {
796 let doc = make_doc(vec![
797 make_section(
798 SectionType::Options,
799 kv(&[("timeout", "10"), ("retry", "3")]),
800 ),
801 make_section_with_attrs(
802 SectionType::Request,
803 SectionContent::Empty,
804 vec![GctfAttribute::new("retry", "5")],
805 ),
806 ]);
807 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
808
809 assert_eq!(result.timeout_seconds.value, 10);
810 assert_eq!(
811 result.timeout_seconds.source,
812 RuntimeOptionSource::FileOptions
813 );
814 assert_eq!(result.retry.value, 5);
815 assert_eq!(result.retry.source, RuntimeOptionSource::SectionAttribute);
816 assert_eq!(result.retry_delay_seconds.value, 1.0);
817 assert_eq!(
818 result.retry_delay_seconds.source,
819 RuntimeOptionSource::CliDefaults
820 );
821 }
822
823 #[test]
824 fn test_resolve_kebab_alias_in_options() {
825 let doc = make_doc(vec![
826 make_section(
827 SectionType::Options,
828 kv(&[("retry-delay", "0.2"), ("no-retry", "true")]),
829 ),
830 make_section(
831 SectionType::Endpoint,
832 SectionContent::Single("svc/Method".into()),
833 ),
834 make_section(SectionType::Request, SectionContent::Empty),
835 ]);
836 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
837
838 assert_eq!(result.retry_delay_seconds.value, 0.2);
839 assert_eq!(
840 result.retry_delay_seconds.source,
841 RuntimeOptionSource::FileOptions
842 );
843 assert!(result.no_retry.value);
844 assert_eq!(result.no_retry.source, RuntimeOptionSource::FileOptions);
845 }
846
847 #[test]
848 fn test_resolve_kebab_alias_in_attributes() {
849 let doc = make_doc(vec![make_section_with_attrs(
850 SectionType::Request,
851 SectionContent::Empty,
852 vec![
853 GctfAttribute::new("retry-delay", "0.3"),
854 GctfAttribute::new("no-retry", "true"),
855 ],
856 )]);
857 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
858
859 assert_eq!(result.retry_delay_seconds.value, 0.3);
860 assert_eq!(
861 result.retry_delay_seconds.source,
862 RuntimeOptionSource::SectionAttribute
863 );
864 assert!(result.no_retry.value);
865 assert_eq!(
866 result.no_retry.source,
867 RuntimeOptionSource::SectionAttribute
868 );
869 }
870
871 #[test]
872 fn test_resolve_error_invalid_timeout() {
873 let doc = make_doc(vec![
874 make_section(SectionType::Options, kv(&[("timeout", "abc")])),
875 make_section(
876 SectionType::Endpoint,
877 SectionContent::Single("svc/Method".into()),
878 ),
879 ]);
880 let result = resolve_effective_runtime_options(&doc, cli_defaults());
881 assert!(result.is_err());
882 assert!(result.unwrap_err().contains("timeout"));
883 }
884
885 #[test]
886 fn test_resolve_error_zero_timeout() {
887 let doc = make_doc(vec![
888 make_section(SectionType::Options, kv(&[("timeout", "0")])),
889 make_section(
890 SectionType::Endpoint,
891 SectionContent::Single("svc/Method".into()),
892 ),
893 ]);
894 let result = resolve_effective_runtime_options(&doc, cli_defaults());
895 assert!(result.is_err());
896 }
897
898 #[test]
899 fn test_resolve_error_invalid_retry() {
900 let doc = make_doc(vec![
901 make_section(SectionType::Options, kv(&[("retry", "abc")])),
902 make_section(
903 SectionType::Endpoint,
904 SectionContent::Single("svc/Method".into()),
905 ),
906 ]);
907 let result = resolve_effective_runtime_options(&doc, cli_defaults());
908 assert!(result.is_err());
909 assert!(result.unwrap_err().contains("retry"));
910 }
911
912 #[test]
913 fn test_resolve_error_invalid_retry_delay() {
914 let doc = make_doc(vec![
915 make_section(SectionType::Options, kv(&[("retry_delay", "abc")])),
916 make_section(
917 SectionType::Endpoint,
918 SectionContent::Single("svc/Method".into()),
919 ),
920 ]);
921 let result = resolve_effective_runtime_options(&doc, cli_defaults());
922 assert!(result.is_err());
923 assert!(result.unwrap_err().contains("retry_delay"));
924 }
925
926 #[test]
927 fn test_resolve_error_negative_retry_delay() {
928 let doc = make_doc(vec![
929 make_section(SectionType::Options, kv(&[("retry_delay", "-1.0")])),
930 make_section(
931 SectionType::Endpoint,
932 SectionContent::Single("svc/Method".into()),
933 ),
934 ]);
935 let result = resolve_effective_runtime_options(&doc, cli_defaults());
936 assert!(result.is_err());
937 }
938
939 #[test]
940 fn test_resolve_error_invalid_no_retry() {
941 let doc = make_doc(vec![
942 make_section(SectionType::Options, kv(&[("no_retry", "maybe")])),
943 make_section(
944 SectionType::Endpoint,
945 SectionContent::Single("svc/Method".into()),
946 ),
947 ]);
948 let result = resolve_effective_runtime_options(&doc, cli_defaults());
949 assert!(result.is_err());
950 assert!(result.unwrap_err().contains("no_retry"));
951 }
952
953 #[test]
954 fn test_resolve_zero_timeout_attribute_ignored() {
955 let doc = make_doc(vec![make_section_with_attrs(
956 SectionType::Request,
957 SectionContent::Empty,
958 vec![GctfAttribute::new("timeout", "0")],
959 )]);
960 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
961
962 assert_eq!(result.timeout_seconds.value, 30);
963 assert_eq!(
964 result.timeout_seconds.source,
965 RuntimeOptionSource::CliDefaults
966 );
967 }
968
969 #[test]
970 fn test_resolve_negative_retry_delay_attribute_ignored() {
971 let doc = make_doc(vec![make_section_with_attrs(
972 SectionType::Request,
973 SectionContent::Empty,
974 vec![GctfAttribute::new("retry_delay", "-0.5")],
975 )]);
976 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
977
978 assert_eq!(result.retry_delay_seconds.value, 1.0);
979 assert_eq!(
980 result.retry_delay_seconds.source,
981 RuntimeOptionSource::CliDefaults
982 );
983 }
984
985 #[test]
986 fn test_resolve_compression_from_options() {
987 let doc = make_doc(vec![
988 make_section(SectionType::Options, kv(&[("compression", "gzip")])),
989 make_section(
990 SectionType::Endpoint,
991 SectionContent::Single("svc/Method".into()),
992 ),
993 ]);
994 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
995
996 assert_eq!(result.compression.value, "gzip");
997 assert_eq!(result.compression.source, RuntimeOptionSource::FileOptions);
998 }
999
1000 #[test]
1001 fn test_resolve_compression_defaults() {
1002 let doc = make_doc(vec![make_section(
1003 SectionType::Endpoint,
1004 SectionContent::Single("svc/Method".into()),
1005 )]);
1006 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1007
1008 assert_eq!(result.compression.source, RuntimeOptionSource::CliDefaults);
1009 }
1010
1011 #[test]
1012 fn test_resolve_json_serialization() {
1013 let doc = make_doc(vec![
1014 make_section(
1015 SectionType::Options,
1016 kv(&[("timeout", "10"), ("retry", "3")]),
1017 ),
1018 make_section(
1019 SectionType::Endpoint,
1020 SectionContent::Single("svc/Method".into()),
1021 ),
1022 ]);
1023 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1024
1025 let json = serde_json::to_value(&result).unwrap();
1026 let obj = json.as_object().unwrap();
1027 assert!(obj.contains_key("timeout_seconds"));
1028 assert!(obj.contains_key("retry"));
1029 assert!(obj.contains_key("retry_delay_seconds"));
1030 assert!(obj.contains_key("no_retry"));
1031 assert!(obj.contains_key("compression"));
1032
1033 let ts = &obj["timeout_seconds"];
1034 assert_eq!(ts["value"], 10);
1035 assert_eq!(ts["source"], "file_options");
1036 }
1037
1038 #[test]
1039 fn test_runtime_option_source_serde_roundtrip() {
1040 let sources = vec![
1041 RuntimeOptionSource::SectionAttribute,
1042 RuntimeOptionSource::FileOptions,
1043 RuntimeOptionSource::CliDefaults,
1044 ];
1045 for source in sources {
1046 let json = serde_json::to_string(&source).unwrap();
1047 let back: RuntimeOptionSource = serde_json::from_str(&json).unwrap();
1048 assert_eq!(source, back);
1049 }
1050 }
1051
1052 #[test]
1053 fn test_effective_runtime_options_clone_roundtrip() {
1054 let doc = make_doc(vec![
1055 make_section(SectionType::Options, kv(&[("timeout", "10")])),
1056 make_section(
1057 SectionType::Endpoint,
1058 SectionContent::Single("svc/Method".into()),
1059 ),
1060 ]);
1061 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1062 let cloned = result.clone();
1063 assert_eq!(cloned.timeout_seconds.value, 10);
1064 assert_eq!(
1065 cloned.timeout_seconds.source,
1066 RuntimeOptionSource::FileOptions
1067 );
1068 }
1069
1070 #[test]
1071 fn test_resolve_compression_from_section_attribute() {
1072 let doc = make_doc(vec![make_section_with_attrs(
1073 SectionType::Request,
1074 SectionContent::Empty,
1075 vec![GctfAttribute::new("compression", "gzip")],
1076 )]);
1077 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1078
1079 assert_eq!(result.compression.value, "gzip");
1080 assert_eq!(
1081 result.compression.source,
1082 RuntimeOptionSource::SectionAttribute
1083 );
1084 }
1085
1086 #[test]
1087 fn test_resolve_compression_attribute_overrides_file_options() {
1088 let doc = make_doc(vec![
1089 make_section(SectionType::Options, kv(&[("compression", "none")])),
1090 make_section_with_attrs(
1091 SectionType::Request,
1092 SectionContent::Empty,
1093 vec![GctfAttribute::new("compression", "gzip")],
1094 ),
1095 ]);
1096 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1097
1098 assert_eq!(result.compression.value, "gzip");
1099 assert_eq!(
1100 result.compression.source,
1101 RuntimeOptionSource::SectionAttribute
1102 );
1103 }
1104
1105 #[test]
1106 fn test_resolve_compression_attribute_none_value() {
1107 let doc = make_doc(vec![make_section_with_attrs(
1108 SectionType::Request,
1109 SectionContent::Empty,
1110 vec![GctfAttribute::new("compression", "none")],
1111 )]);
1112 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1113
1114 assert_eq!(result.compression.value, "none");
1115 assert_eq!(
1116 result.compression.source,
1117 RuntimeOptionSource::SectionAttribute
1118 );
1119 }
1120
1121 #[test]
1122 fn test_resolve_compression_invalid_options_value_errors() {
1123 let doc = make_doc(vec![
1126 make_section(SectionType::Options, kv(&[("compression", "brotli")])),
1127 make_section(
1128 SectionType::Endpoint,
1129 SectionContent::Single("svc/Method".into()),
1130 ),
1131 ]);
1132 let result = resolve_effective_runtime_options(&doc, cli_defaults());
1133 assert!(result.is_err());
1134 assert!(result.unwrap_err().contains("compression"));
1135 }
1136
1137 #[test]
1138 fn test_resolve_compression_invalid_attribute_value_falls_back() {
1139 let doc = make_doc(vec![
1140 make_section(SectionType::Options, kv(&[("compression", "gzip")])),
1141 make_section_with_attrs(
1142 SectionType::Request,
1143 SectionContent::Empty,
1144 vec![GctfAttribute::new("compression", "invalid")],
1145 ),
1146 ]);
1147 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1148
1149 assert_eq!(result.compression.value, "gzip");
1150 assert_eq!(result.compression.source, RuntimeOptionSource::FileOptions);
1151 }
1152
1153 fn opts(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1154 pairs
1155 .iter()
1156 .map(|(k, v)| (k.to_string(), v.to_string()))
1157 .collect()
1158 }
1159
1160 #[test]
1161 fn test_resolve_compression_attribute_beats_options() {
1162 let doc = make_doc(vec![
1163 make_section(SectionType::Options, kv(&[("compression", "none")])),
1164 make_section_with_attrs(
1165 SectionType::Request,
1166 SectionContent::Empty,
1167 vec![GctfAttribute::new("compression", "gzip")],
1168 ),
1169 ]);
1170 assert_eq!(
1171 resolve_compression(
1172 &doc,
1173 &opts(&[("compression", "none")]),
1174 CompressionMode::None
1175 ),
1176 Ok(CompressionMode::Gzip)
1177 );
1178 }
1179
1180 #[test]
1181 fn test_resolve_compression_options_used_when_no_attribute() {
1182 let doc = make_doc(vec![make_section(
1183 SectionType::Endpoint,
1184 SectionContent::Single("svc/M".into()),
1185 )]);
1186 assert_eq!(
1187 resolve_compression(
1188 &doc,
1189 &opts(&[("compression", "gzip")]),
1190 CompressionMode::None
1191 ),
1192 Ok(CompressionMode::Gzip)
1193 );
1194 }
1195
1196 #[test]
1197 fn test_resolve_compression_invalid_options_is_error_not_fallback() {
1198 let doc = make_doc(vec![make_section(
1199 SectionType::Endpoint,
1200 SectionContent::Single("svc/M".into()),
1201 )]);
1202 let result = resolve_compression(
1203 &doc,
1204 &opts(&[("compression", "brotli")]),
1205 CompressionMode::Gzip,
1206 );
1207 assert!(result.is_err());
1208 assert!(result.unwrap_err().contains("compression"));
1209 }
1210
1211 #[test]
1212 fn test_resolve_compression_env_default_when_unset() {
1213 let doc = make_doc(vec![make_section(
1214 SectionType::Endpoint,
1215 SectionContent::Single("svc/M".into()),
1216 )]);
1217 assert_eq!(
1218 resolve_compression(&doc, &HashMap::new(), CompressionMode::Gzip),
1219 Ok(CompressionMode::Gzip)
1220 );
1221 }
1222
1223 #[test]
1225 fn test_collect_unresolved_placeholder_undefined_in_body() {
1226 let vars = HashMap::new();
1227 let mut body = serde_json::json!({ "user": "{{missing}}" });
1228 substitute_variables(&mut body, &vars);
1229 let mut unresolved = Vec::new();
1230 collect_unresolved_placeholders(&body, &vars, &mut unresolved);
1231 assert_eq!(unresolved, vec!["missing".to_string()]);
1232 assert_eq!(format_unresolved_placeholders(&unresolved), "{{missing}}");
1233 }
1234
1235 #[test]
1237 fn test_collect_unresolved_placeholder_bound_variable_ok() {
1238 let mut vars = HashMap::new();
1239 vars.insert("user_id".to_string(), Value::from(42));
1240 let mut body = serde_json::json!({ "id": "{{user_id}}", "note": "u={{user_id}}" });
1241 substitute_variables(&mut body, &vars);
1242 assert_eq!(body["id"], Value::from(42));
1243 assert_eq!(body["note"], Value::from("u=42"));
1244 let mut unresolved = Vec::new();
1245 collect_unresolved_placeholders(&body, &vars, &mut unresolved);
1246 assert!(unresolved.is_empty());
1247 }
1248
1249 #[test]
1251 fn test_find_unresolved_placeholder_in_header_value() {
1252 let vars = HashMap::new();
1253 let header_value =
1254 interpolate_variables("Bearer {{token}}", &vars).unwrap_or("Bearer {{token}}".into());
1255 let mut unresolved = Vec::new();
1256 find_unresolved_placeholders(&header_value, &vars, &mut unresolved);
1257 assert_eq!(unresolved, vec!["token".to_string()]);
1258 }
1259
1260 #[test]
1262 fn test_collect_unresolved_placeholder_ignores_non_placeholder_literals() {
1263 let vars = HashMap::new();
1264 let body = serde_json::json!({
1265 "json_like": "{ \"a\": 1 }",
1266 "shell": "${HOME}",
1267 "free_text": "{{ not a var }}",
1268 "empty": "{{}}"
1269 });
1270 let mut unresolved = Vec::new();
1271 collect_unresolved_placeholders(&body, &vars, &mut unresolved);
1272 assert!(unresolved.is_empty(), "unexpected: {:?}", unresolved);
1273 }
1274}