Skip to main content

apif_execution/
helpers.rs

1//! Helper utilities for the test runner.
2//!
3//! Contains pure functions and static helpers used by the test runner
4//! that don't require `self` access: variable substitution, TLS defaults,
5//! JSON formatting, and metadata conversion.
6
7use 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
18/// Buffer size for the request message channel.
19/// Controls back-pressure for client streaming: larger values allow more
20/// buffered requests but consume more memory.
21pub const REQUEST_CHANNEL_BUFFER: usize = 100;
22
23/// Default TLS configuration from environment variables.
24pub 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
51/// Parse truthy values from config-style strings.
52pub 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
59/// Resolve effective address using ADDRESS section, env var, then protocol-dependent default.
60/// `protocol_override` takes precedence over OPTIONS.protocol from the document.
61pub 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
79/// Resolve compression setting from OPTIONS section with env fallback.
80pub 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
301/// Resolve a TLS file path relative to document or CWD.
302pub 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
322/// Build TLS config using TLS section and env defaults, matching run behavior.
323pub 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
375/// Build proto config with document-relative path resolution, matching run behavior.
376pub 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
418/// Build full service name from package and service.
419pub 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
427/// Format JSON value for display.
428pub fn format_json_pretty(value: &Value) -> String {
429    serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
430}
431
432/// Interpolate variables in a string template.
433/// Replaces `{{var}}` patterns with values from the variables map.
434/// Returns `None` if no substitutions were made.
435pub 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
473/// Recursively substitute variables in a JSON value.
474/// If a string is exactly `{{var}}`, it's replaced with the actual Value type.
475/// Otherwise, string interpolation is performed.
476pub 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 nothing changed, restore original (type-preserving)
493            if *s == original {
494                // No change
495            }
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
511/// Convert tonic metadata map to HashMap.
512pub 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}