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() -> 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
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: &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
91/// Resolve the effective compression mode honoring the canonical precedence
92/// `section attribute > OPTIONS > env default`. An explicit-but-unknown value
93/// at either level is a configuration error (never a silent fall-back).
94pub fn resolve_compression(
95    document: &GctfDocument,
96    options: &apif_ast::OrderedStringMap,
97    env_default: CompressionMode,
98) -> Result<CompressionMode, String> {
99    // `get_compression` only yields validated "gzip"/"none" (an invalid attribute
100    // value is filtered to None and falls through to OPTIONS/env, matching
101    // `resolve_effective_runtime_options`).
102    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    // Canonical compression precedence: section attribute > OPTIONS > env/CLI default.
301    // `execution::runner` resolves the effective mode via `resolve_compression`
302    // (same precedence), so this reporting path and the runner agree; both error
303    // on an explicit-but-invalid value rather than silently falling back.
304    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        // An explicit-but-unknown OPTIONS.compression is a configuration error,
316        // not a silent fall-back to `none`.
317        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
349/// Resolve a TLS file path relative to document or CWD.
350pub 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
370/// Build TLS config using TLS section and env defaults, matching run behavior.
371pub 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
423/// Build proto config with document-relative path resolution, matching run behavior.
424pub 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
466/// Build full service name from package and service.
467pub 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
475/// Format JSON value for display.
476pub fn format_json_pretty(value: &Value) -> String {
477    serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
478}
479
480/// Interpolate variables in a string template.
481/// Replaces `{{var}}` patterns with values from the variables map.
482/// Returns `None` if no substitutions were made.
483pub 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
525/// Recursively substitute variables in a JSON value.
526/// If a string is exactly `{{var}}`, it's replaced with the actual Value type.
527/// Otherwise, string interpolation is performed.
528pub 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
558/// True when `body` is a single well-formed variable identifier, i.e. the kind
559/// of placeholder `interpolate_variables`/`substitute_variables` treat as a
560/// variable reference. This deliberately rejects anything with spaces or
561/// punctuation so ordinary strings that merely contain `{{` (JSON fragments,
562/// free text) are never mistaken for an unresolved placeholder.
563fn 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
569/// Append the names of unresolved `{{ identifier }}` placeholders found in
570/// `text` to `out`. A placeholder is unresolved when its body is a well-formed
571/// variable identifier that is absent from `variables` (i.e. the substitutor
572/// would have left it verbatim). Names are de-duplicated, order preserved.
573pub 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
596/// Recursively collect unresolved `{{ identifier }}` placeholders from all
597/// string values in a JSON `value` (used to guard outgoing request bodies).
598pub 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
619/// Format unresolved variable names back as `{{a}}, {{b}}` for error messages.
620pub fn format_unresolved_placeholders(names: &[String]) -> String {
621    names
622        .iter()
623        .map(|n| format!("{{{{{n}}}}}"))
624        .collect::<Vec<_>>()
625        .join(", ")
626}
627
628/// Convert tonic metadata map to HashMap.
629pub 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        // An explicit-but-unknown OPTIONS.compression must be a hard error, not
1131        // a silent fall-back to `none`.
1132        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    // (1) An undefined variable in a request body must be reported, not sent.
1235    #[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    // (2) A defined variable still substitutes and is not flagged.
1247    #[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    // (3) An unresolved placeholder in a header value is detected.
1261    #[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    // (4) A legitimate literal that merely contains braces is not false-flagged.
1272    #[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}