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