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() -> apif_ast::OrderedStringMap {
25 let mut defaults = apif_ast::OrderedStringMap::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: &apif_ast::OrderedStringMap) -> 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: &apif_ast::OrderedStringMap,
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 tracing::trace!("interpolate: {{{{{var_name}}}}} -> {var_value:?}");
499 if let Value::String(s) = var_value {
500 out.push_str(s);
501 } else {
502 out.push_str(&var_value.to_string());
503 }
504 changed = true;
505 } else {
506 tracing::trace!(
507 "interpolate: {{{{{var_name}}}}} has no matching variable, left as-is"
508 );
509 out.push_str(&template[open..close + 2]);
510 }
511 cursor = close + 2;
512 } else {
513 out.push_str(&template[cursor..]);
514 break;
515 }
516 }
517
518 if cursor < template.len() {
519 out.push_str(&template[cursor..]);
520 }
521
522 if changed { Some(out) } else { None }
523}
524
525pub fn substitute_variables(value: &mut Value, variables: &HashMap<String, Value>) {
529 match value {
530 Value::String(s) => {
531 if s.starts_with("{{") && s.ends_with("}}") {
532 let inner = s[2..s.len() - 2].trim();
533 if !inner.contains("{{")
534 && let Some(val) = variables.get(inner)
535 {
536 *value = val.clone();
537 return;
538 }
539 }
540 if let Some(replaced) = interpolate_variables(s, variables) {
541 *s = replaced;
542 }
543 }
544 Value::Array(items) => {
545 for item in items {
546 substitute_variables(item, variables);
547 }
548 }
549 Value::Object(map) => {
550 for (_, val) in map.iter_mut() {
551 substitute_variables(val, variables);
552 }
553 }
554 _ => {}
555 }
556}
557
558fn is_variable_placeholder(body: &str) -> bool {
564 let mut chars = body.chars();
565 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
566 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
567}
568
569pub fn find_unresolved_placeholders(
574 text: &str,
575 variables: &HashMap<String, Value>,
576 out: &mut Vec<String>,
577) {
578 let mut cursor = 0usize;
579 while let Some(open_rel) = text[cursor..].find("{{") {
580 let after_open = cursor + open_rel + 2;
581 let Some(close_rel) = text[after_open..].find("}}") else {
582 break;
583 };
584 let close = after_open + close_rel;
585 let name = text[after_open..close].trim();
586 if is_variable_placeholder(name)
587 && !variables.contains_key(name)
588 && !out.iter().any(|n| n == name)
589 {
590 out.push(name.to_string());
591 }
592 cursor = close + 2;
593 }
594}
595
596pub fn collect_unresolved_placeholders(
599 value: &Value,
600 variables: &HashMap<String, Value>,
601 out: &mut Vec<String>,
602) {
603 match value {
604 Value::String(s) => find_unresolved_placeholders(s, variables, out),
605 Value::Array(items) => {
606 for item in items {
607 collect_unresolved_placeholders(item, variables, out);
608 }
609 }
610 Value::Object(map) => {
611 for val in map.values() {
612 collect_unresolved_placeholders(val, variables, out);
613 }
614 }
615 _ => {}
616 }
617}
618
619pub fn format_unresolved_placeholders(names: &[String]) -> String {
621 names
622 .iter()
623 .map(|n| format!("{{{{{n}}}}}"))
624 .collect::<Vec<_>>()
625 .join(", ")
626}
627
628pub fn metadata_map_to_hashmap(metadata: &tonic::metadata::MetadataMap) -> HashMap<String, String> {
630 let mut out = HashMap::new();
631 for kv in metadata.iter() {
632 if let tonic::metadata::KeyAndValueRef::Ascii(key, value) = kv {
633 if let Ok(v) = value.to_str() {
634 out.insert(key.to_string(), v.to_string());
635 }
636 } else if let tonic::metadata::KeyAndValueRef::Binary(key, value) = kv {
637 out.insert(key.to_string(), format!("{:?}", value));
638 }
639 }
640 out
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646 use apif_ast::{
647 GctfAttribute, GctfDocument, Section, SectionContent, SectionSpan, SectionType,
648 };
649
650 fn make_doc(sections: Vec<Section>) -> GctfDocument {
651 GctfDocument {
652 file_path: "test.gctf".to_string(),
653 sections,
654 metadata: Default::default(),
655 next_document: None,
656 }
657 }
658
659 fn make_section(section_type: SectionType, content: SectionContent) -> Section {
660 Section {
661 section_type,
662 content,
663 inline_options: Default::default(),
664 raw_content: String::new(),
665 start_line: 0,
666 end_line: 0,
667 attributes: Vec::new(),
668 span: SectionSpan::default(),
669 }
670 }
671
672 fn make_section_with_attrs(
673 section_type: SectionType,
674 content: SectionContent,
675 attrs: Vec<GctfAttribute>,
676 ) -> Section {
677 let mut s = make_section(section_type, content);
678 s.attributes = attrs;
679 s
680 }
681
682 fn cli_defaults() -> CliRuntimeDefaults {
683 CliRuntimeDefaults {
684 timeout_seconds: 30,
685 retry: 0,
686 retry_delay_seconds: 1.0,
687 no_retry: false,
688 }
689 }
690
691 fn kv(map: &[(&str, &str)]) -> SectionContent {
692 SectionContent::KeyValues(
693 map.iter()
694 .map(|(k, v)| (k.to_string(), v.to_string()))
695 .collect(),
696 )
697 }
698
699 #[test]
700 fn test_resolve_defaults_only() {
701 let doc = make_doc(vec![
702 make_section(
703 SectionType::Endpoint,
704 SectionContent::Single("svc/Method".into()),
705 ),
706 make_section(SectionType::Request, SectionContent::Empty),
707 ]);
708 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
709
710 assert_eq!(result.timeout_seconds.value, 30);
711 assert_eq!(
712 result.timeout_seconds.source,
713 RuntimeOptionSource::CliDefaults
714 );
715 assert_eq!(result.retry.value, 0);
716 assert_eq!(result.retry.source, RuntimeOptionSource::CliDefaults);
717 assert_eq!(result.retry_delay_seconds.value, 1.0);
718 assert_eq!(
719 result.retry_delay_seconds.source,
720 RuntimeOptionSource::CliDefaults
721 );
722 assert!(!result.no_retry.value);
723 assert_eq!(result.no_retry.source, RuntimeOptionSource::CliDefaults);
724 }
725
726 #[test]
727 fn test_resolve_file_options_override_defaults() {
728 let doc = make_doc(vec![
729 make_section(
730 SectionType::Options,
731 kv(&[
732 ("timeout", "10"),
733 ("retry", "3"),
734 ("retry_delay", "0.5"),
735 ("no_retry", "true"),
736 ]),
737 ),
738 make_section(
739 SectionType::Endpoint,
740 SectionContent::Single("svc/Method".into()),
741 ),
742 make_section(SectionType::Request, SectionContent::Empty),
743 ]);
744 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
745
746 assert_eq!(result.timeout_seconds.value, 10);
747 assert_eq!(
748 result.timeout_seconds.source,
749 RuntimeOptionSource::FileOptions
750 );
751 assert_eq!(result.retry.value, 3);
752 assert_eq!(result.retry.source, RuntimeOptionSource::FileOptions);
753 assert_eq!(result.retry_delay_seconds.value, 0.5);
754 assert_eq!(
755 result.retry_delay_seconds.source,
756 RuntimeOptionSource::FileOptions
757 );
758 assert!(result.no_retry.value);
759 assert_eq!(result.no_retry.source, RuntimeOptionSource::FileOptions);
760 }
761
762 #[test]
763 fn test_resolve_section_attribute_overrides_file_options() {
764 let doc = make_doc(vec![
765 make_section(
766 SectionType::Options,
767 kv(&[("timeout", "10"), ("retry", "3"), ("retry_delay", "0.5")]),
768 ),
769 make_section_with_attrs(
770 SectionType::Request,
771 SectionContent::Empty,
772 vec![
773 GctfAttribute::new("timeout", "5"),
774 GctfAttribute::new("retry", "7"),
775 GctfAttribute::new("retry_delay", "2.0"),
776 GctfAttribute::flag("no_retry"),
777 ],
778 ),
779 ]);
780 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
781
782 assert_eq!(result.timeout_seconds.value, 5);
783 assert_eq!(
784 result.timeout_seconds.source,
785 RuntimeOptionSource::SectionAttribute
786 );
787 assert_eq!(result.retry.value, 7);
788 assert_eq!(result.retry.source, RuntimeOptionSource::SectionAttribute);
789 assert_eq!(result.retry_delay_seconds.value, 2.0);
790 assert_eq!(
791 result.retry_delay_seconds.source,
792 RuntimeOptionSource::SectionAttribute
793 );
794 assert!(result.no_retry.value);
795 assert_eq!(
796 result.no_retry.source,
797 RuntimeOptionSource::SectionAttribute
798 );
799 }
800
801 #[test]
802 fn test_resolve_attribute_overrides_options_only_for_present_fields() {
803 let doc = make_doc(vec![
804 make_section(
805 SectionType::Options,
806 kv(&[("timeout", "10"), ("retry", "3")]),
807 ),
808 make_section_with_attrs(
809 SectionType::Request,
810 SectionContent::Empty,
811 vec![GctfAttribute::new("retry", "5")],
812 ),
813 ]);
814 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
815
816 assert_eq!(result.timeout_seconds.value, 10);
817 assert_eq!(
818 result.timeout_seconds.source,
819 RuntimeOptionSource::FileOptions
820 );
821 assert_eq!(result.retry.value, 5);
822 assert_eq!(result.retry.source, RuntimeOptionSource::SectionAttribute);
823 assert_eq!(result.retry_delay_seconds.value, 1.0);
824 assert_eq!(
825 result.retry_delay_seconds.source,
826 RuntimeOptionSource::CliDefaults
827 );
828 }
829
830 #[test]
831 fn test_resolve_kebab_alias_in_options() {
832 let doc = make_doc(vec![
833 make_section(
834 SectionType::Options,
835 kv(&[("retry-delay", "0.2"), ("no-retry", "true")]),
836 ),
837 make_section(
838 SectionType::Endpoint,
839 SectionContent::Single("svc/Method".into()),
840 ),
841 make_section(SectionType::Request, SectionContent::Empty),
842 ]);
843 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
844
845 assert_eq!(result.retry_delay_seconds.value, 0.2);
846 assert_eq!(
847 result.retry_delay_seconds.source,
848 RuntimeOptionSource::FileOptions
849 );
850 assert!(result.no_retry.value);
851 assert_eq!(result.no_retry.source, RuntimeOptionSource::FileOptions);
852 }
853
854 #[test]
855 fn test_resolve_kebab_alias_in_attributes() {
856 let doc = make_doc(vec![make_section_with_attrs(
857 SectionType::Request,
858 SectionContent::Empty,
859 vec![
860 GctfAttribute::new("retry-delay", "0.3"),
861 GctfAttribute::new("no-retry", "true"),
862 ],
863 )]);
864 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
865
866 assert_eq!(result.retry_delay_seconds.value, 0.3);
867 assert_eq!(
868 result.retry_delay_seconds.source,
869 RuntimeOptionSource::SectionAttribute
870 );
871 assert!(result.no_retry.value);
872 assert_eq!(
873 result.no_retry.source,
874 RuntimeOptionSource::SectionAttribute
875 );
876 }
877
878 #[test]
879 fn test_resolve_error_invalid_timeout() {
880 let doc = make_doc(vec![
881 make_section(SectionType::Options, kv(&[("timeout", "abc")])),
882 make_section(
883 SectionType::Endpoint,
884 SectionContent::Single("svc/Method".into()),
885 ),
886 ]);
887 let result = resolve_effective_runtime_options(&doc, cli_defaults());
888 assert!(result.is_err());
889 assert!(result.unwrap_err().contains("timeout"));
890 }
891
892 #[test]
893 fn test_resolve_error_zero_timeout() {
894 let doc = make_doc(vec![
895 make_section(SectionType::Options, kv(&[("timeout", "0")])),
896 make_section(
897 SectionType::Endpoint,
898 SectionContent::Single("svc/Method".into()),
899 ),
900 ]);
901 let result = resolve_effective_runtime_options(&doc, cli_defaults());
902 assert!(result.is_err());
903 }
904
905 #[test]
906 fn test_resolve_error_invalid_retry() {
907 let doc = make_doc(vec![
908 make_section(SectionType::Options, kv(&[("retry", "abc")])),
909 make_section(
910 SectionType::Endpoint,
911 SectionContent::Single("svc/Method".into()),
912 ),
913 ]);
914 let result = resolve_effective_runtime_options(&doc, cli_defaults());
915 assert!(result.is_err());
916 assert!(result.unwrap_err().contains("retry"));
917 }
918
919 #[test]
920 fn test_resolve_error_invalid_retry_delay() {
921 let doc = make_doc(vec![
922 make_section(SectionType::Options, kv(&[("retry_delay", "abc")])),
923 make_section(
924 SectionType::Endpoint,
925 SectionContent::Single("svc/Method".into()),
926 ),
927 ]);
928 let result = resolve_effective_runtime_options(&doc, cli_defaults());
929 assert!(result.is_err());
930 assert!(result.unwrap_err().contains("retry_delay"));
931 }
932
933 #[test]
934 fn test_resolve_error_negative_retry_delay() {
935 let doc = make_doc(vec![
936 make_section(SectionType::Options, kv(&[("retry_delay", "-1.0")])),
937 make_section(
938 SectionType::Endpoint,
939 SectionContent::Single("svc/Method".into()),
940 ),
941 ]);
942 let result = resolve_effective_runtime_options(&doc, cli_defaults());
943 assert!(result.is_err());
944 }
945
946 #[test]
947 fn test_resolve_error_invalid_no_retry() {
948 let doc = make_doc(vec![
949 make_section(SectionType::Options, kv(&[("no_retry", "maybe")])),
950 make_section(
951 SectionType::Endpoint,
952 SectionContent::Single("svc/Method".into()),
953 ),
954 ]);
955 let result = resolve_effective_runtime_options(&doc, cli_defaults());
956 assert!(result.is_err());
957 assert!(result.unwrap_err().contains("no_retry"));
958 }
959
960 #[test]
961 fn test_resolve_zero_timeout_attribute_ignored() {
962 let doc = make_doc(vec![make_section_with_attrs(
963 SectionType::Request,
964 SectionContent::Empty,
965 vec![GctfAttribute::new("timeout", "0")],
966 )]);
967 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
968
969 assert_eq!(result.timeout_seconds.value, 30);
970 assert_eq!(
971 result.timeout_seconds.source,
972 RuntimeOptionSource::CliDefaults
973 );
974 }
975
976 #[test]
977 fn test_resolve_negative_retry_delay_attribute_ignored() {
978 let doc = make_doc(vec![make_section_with_attrs(
979 SectionType::Request,
980 SectionContent::Empty,
981 vec![GctfAttribute::new("retry_delay", "-0.5")],
982 )]);
983 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
984
985 assert_eq!(result.retry_delay_seconds.value, 1.0);
986 assert_eq!(
987 result.retry_delay_seconds.source,
988 RuntimeOptionSource::CliDefaults
989 );
990 }
991
992 #[test]
993 fn test_resolve_compression_from_options() {
994 let doc = make_doc(vec![
995 make_section(SectionType::Options, kv(&[("compression", "gzip")])),
996 make_section(
997 SectionType::Endpoint,
998 SectionContent::Single("svc/Method".into()),
999 ),
1000 ]);
1001 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1002
1003 assert_eq!(result.compression.value, "gzip");
1004 assert_eq!(result.compression.source, RuntimeOptionSource::FileOptions);
1005 }
1006
1007 #[test]
1008 fn test_resolve_compression_defaults() {
1009 let doc = make_doc(vec![make_section(
1010 SectionType::Endpoint,
1011 SectionContent::Single("svc/Method".into()),
1012 )]);
1013 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1014
1015 assert_eq!(result.compression.source, RuntimeOptionSource::CliDefaults);
1016 }
1017
1018 #[test]
1019 fn test_resolve_json_serialization() {
1020 let doc = make_doc(vec![
1021 make_section(
1022 SectionType::Options,
1023 kv(&[("timeout", "10"), ("retry", "3")]),
1024 ),
1025 make_section(
1026 SectionType::Endpoint,
1027 SectionContent::Single("svc/Method".into()),
1028 ),
1029 ]);
1030 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1031
1032 let json = serde_json::to_value(&result).unwrap();
1033 let obj = json.as_object().unwrap();
1034 assert!(obj.contains_key("timeout_seconds"));
1035 assert!(obj.contains_key("retry"));
1036 assert!(obj.contains_key("retry_delay_seconds"));
1037 assert!(obj.contains_key("no_retry"));
1038 assert!(obj.contains_key("compression"));
1039
1040 let ts = &obj["timeout_seconds"];
1041 assert_eq!(ts["value"], 10);
1042 assert_eq!(ts["source"], "file_options");
1043 }
1044
1045 #[test]
1046 fn test_runtime_option_source_serde_roundtrip() {
1047 let sources = vec![
1048 RuntimeOptionSource::SectionAttribute,
1049 RuntimeOptionSource::FileOptions,
1050 RuntimeOptionSource::CliDefaults,
1051 ];
1052 for source in sources {
1053 let json = serde_json::to_string(&source).unwrap();
1054 let back: RuntimeOptionSource = serde_json::from_str(&json).unwrap();
1055 assert_eq!(source, back);
1056 }
1057 }
1058
1059 #[test]
1060 fn test_effective_runtime_options_clone_roundtrip() {
1061 let doc = make_doc(vec![
1062 make_section(SectionType::Options, kv(&[("timeout", "10")])),
1063 make_section(
1064 SectionType::Endpoint,
1065 SectionContent::Single("svc/Method".into()),
1066 ),
1067 ]);
1068 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1069 let cloned = result.clone();
1070 assert_eq!(cloned.timeout_seconds.value, 10);
1071 assert_eq!(
1072 cloned.timeout_seconds.source,
1073 RuntimeOptionSource::FileOptions
1074 );
1075 }
1076
1077 #[test]
1078 fn test_resolve_compression_from_section_attribute() {
1079 let doc = make_doc(vec![make_section_with_attrs(
1080 SectionType::Request,
1081 SectionContent::Empty,
1082 vec![GctfAttribute::new("compression", "gzip")],
1083 )]);
1084 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1085
1086 assert_eq!(result.compression.value, "gzip");
1087 assert_eq!(
1088 result.compression.source,
1089 RuntimeOptionSource::SectionAttribute
1090 );
1091 }
1092
1093 #[test]
1094 fn test_resolve_compression_attribute_overrides_file_options() {
1095 let doc = make_doc(vec![
1096 make_section(SectionType::Options, kv(&[("compression", "none")])),
1097 make_section_with_attrs(
1098 SectionType::Request,
1099 SectionContent::Empty,
1100 vec![GctfAttribute::new("compression", "gzip")],
1101 ),
1102 ]);
1103 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1104
1105 assert_eq!(result.compression.value, "gzip");
1106 assert_eq!(
1107 result.compression.source,
1108 RuntimeOptionSource::SectionAttribute
1109 );
1110 }
1111
1112 #[test]
1113 fn test_resolve_compression_attribute_none_value() {
1114 let doc = make_doc(vec![make_section_with_attrs(
1115 SectionType::Request,
1116 SectionContent::Empty,
1117 vec![GctfAttribute::new("compression", "none")],
1118 )]);
1119 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1120
1121 assert_eq!(result.compression.value, "none");
1122 assert_eq!(
1123 result.compression.source,
1124 RuntimeOptionSource::SectionAttribute
1125 );
1126 }
1127
1128 #[test]
1129 fn test_resolve_compression_invalid_options_value_errors() {
1130 let doc = make_doc(vec![
1133 make_section(SectionType::Options, kv(&[("compression", "brotli")])),
1134 make_section(
1135 SectionType::Endpoint,
1136 SectionContent::Single("svc/Method".into()),
1137 ),
1138 ]);
1139 let result = resolve_effective_runtime_options(&doc, cli_defaults());
1140 assert!(result.is_err());
1141 assert!(result.unwrap_err().contains("compression"));
1142 }
1143
1144 #[test]
1145 fn test_resolve_compression_invalid_attribute_value_falls_back() {
1146 let doc = make_doc(vec![
1147 make_section(SectionType::Options, kv(&[("compression", "gzip")])),
1148 make_section_with_attrs(
1149 SectionType::Request,
1150 SectionContent::Empty,
1151 vec![GctfAttribute::new("compression", "invalid")],
1152 ),
1153 ]);
1154 let result = resolve_effective_runtime_options(&doc, cli_defaults()).unwrap();
1155
1156 assert_eq!(result.compression.value, "gzip");
1157 assert_eq!(result.compression.source, RuntimeOptionSource::FileOptions);
1158 }
1159
1160 fn opts(pairs: &[(&str, &str)]) -> apif_ast::OrderedStringMap {
1161 pairs
1162 .iter()
1163 .map(|(k, v)| (k.to_string(), v.to_string()))
1164 .collect()
1165 }
1166
1167 #[test]
1168 fn test_resolve_compression_attribute_beats_options() {
1169 let doc = make_doc(vec![
1170 make_section(SectionType::Options, kv(&[("compression", "none")])),
1171 make_section_with_attrs(
1172 SectionType::Request,
1173 SectionContent::Empty,
1174 vec![GctfAttribute::new("compression", "gzip")],
1175 ),
1176 ]);
1177 assert_eq!(
1178 resolve_compression(
1179 &doc,
1180 &opts(&[("compression", "none")]),
1181 CompressionMode::None
1182 ),
1183 Ok(CompressionMode::Gzip)
1184 );
1185 }
1186
1187 #[test]
1188 fn test_resolve_compression_options_used_when_no_attribute() {
1189 let doc = make_doc(vec![make_section(
1190 SectionType::Endpoint,
1191 SectionContent::Single("svc/M".into()),
1192 )]);
1193 assert_eq!(
1194 resolve_compression(
1195 &doc,
1196 &opts(&[("compression", "gzip")]),
1197 CompressionMode::None
1198 ),
1199 Ok(CompressionMode::Gzip)
1200 );
1201 }
1202
1203 #[test]
1204 fn test_resolve_compression_invalid_options_is_error_not_fallback() {
1205 let doc = make_doc(vec![make_section(
1206 SectionType::Endpoint,
1207 SectionContent::Single("svc/M".into()),
1208 )]);
1209 let result = resolve_compression(
1210 &doc,
1211 &opts(&[("compression", "brotli")]),
1212 CompressionMode::Gzip,
1213 );
1214 assert!(result.is_err());
1215 assert!(result.unwrap_err().contains("compression"));
1216 }
1217
1218 #[test]
1219 fn test_resolve_compression_env_default_when_unset() {
1220 let doc = make_doc(vec![make_section(
1221 SectionType::Endpoint,
1222 SectionContent::Single("svc/M".into()),
1223 )]);
1224 assert_eq!(
1225 resolve_compression(
1226 &doc,
1227 &apif_ast::OrderedStringMap::new(),
1228 CompressionMode::Gzip
1229 ),
1230 Ok(CompressionMode::Gzip)
1231 );
1232 }
1233
1234 #[test]
1236 fn test_collect_unresolved_placeholder_undefined_in_body() {
1237 let vars = HashMap::new();
1238 let mut body = serde_json::json!({ "user": "{{missing}}" });
1239 substitute_variables(&mut body, &vars);
1240 let mut unresolved = Vec::new();
1241 collect_unresolved_placeholders(&body, &vars, &mut unresolved);
1242 assert_eq!(unresolved, vec!["missing".to_string()]);
1243 assert_eq!(format_unresolved_placeholders(&unresolved), "{{missing}}");
1244 }
1245
1246 #[test]
1248 fn test_collect_unresolved_placeholder_bound_variable_ok() {
1249 let mut vars = HashMap::new();
1250 vars.insert("user_id".to_string(), Value::from(42));
1251 let mut body = serde_json::json!({ "id": "{{user_id}}", "note": "u={{user_id}}" });
1252 substitute_variables(&mut body, &vars);
1253 assert_eq!(body["id"], Value::from(42));
1254 assert_eq!(body["note"], Value::from("u=42"));
1255 let mut unresolved = Vec::new();
1256 collect_unresolved_placeholders(&body, &vars, &mut unresolved);
1257 assert!(unresolved.is_empty());
1258 }
1259
1260 #[test]
1262 fn test_find_unresolved_placeholder_in_header_value() {
1263 let vars = HashMap::new();
1264 let header_value =
1265 interpolate_variables("Bearer {{token}}", &vars).unwrap_or("Bearer {{token}}".into());
1266 let mut unresolved = Vec::new();
1267 find_unresolved_placeholders(&header_value, &vars, &mut unresolved);
1268 assert_eq!(unresolved, vec!["token".to_string()]);
1269 }
1270
1271 #[test]
1273 fn test_collect_unresolved_placeholder_ignores_non_placeholder_literals() {
1274 let vars = HashMap::new();
1275 let body = serde_json::json!({
1276 "json_like": "{ \"a\": 1 }",
1277 "shell": "${HOME}",
1278 "free_text": "{{ not a var }}",
1279 "empty": "{{}}"
1280 });
1281 let mut unresolved = Vec::new();
1282 collect_unresolved_placeholders(&body, &vars, &mut unresolved);
1283 assert!(unresolved.is_empty(), "unexpected: {:?}", unresolved);
1284 }
1285}