use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
};
use crate::{
collection::Collection,
request::{
Assertion, OAuthProfile, RedactionRules, RequestReliabilityPolicyTemplate,
RequestTemplate, ResponseSnapshot, VariableStore, expand_templates,
},
schema::SchemaRegistry,
};
use super::{
Rule, context,
declarations::{
DeclarationSpanIndex, parse_request_collection, register_declaration,
remember_declaration_span, validate_schema_registry,
},
dotenv::resolve_dotenv_path,
legacy_header::normalize_legacy_collection_header,
oauth::parse_oauth_block,
preprocessor,
protocol::SessionRequestTarget,
redaction::apply_redaction_directive,
request::parse_request_template,
spans::{
preprocess_error_to_span, prompt_error_to_span, template_error_to_collection_error,
template_error_to_span,
},
variables::{
assign_environment_variable, assign_global_variable,
assign_global_variable_for_selected_environment_override,
register_pre_dotenv_guard_variable, SecretValueCache,
},
};
#[derive(Debug, Clone, Copy)]
struct SourceSpan {
start: usize,
end: usize,
}
impl SourceSpan {
fn from_pest_span(span: pest::Span<'_>) -> Self {
Self {
start: span.start(),
end: span.end(),
}
}
fn into_pest_span<'a>(self, source: &'a str) -> pest::Span<'a> {
pest::Span::new(source, self.start, self.end()).expect("source span should exist")
}
fn end(self) -> usize {
self.end
}
}
#[derive(Debug, Clone)]
struct RawScalarAssignment {
key: String,
value: String,
span: SourceSpan,
}
#[derive(Debug, Clone)]
struct RawDotenvDirective {
path: String,
guard: Option<String>,
span: SourceSpan,
}
#[derive(Debug, Clone)]
struct RawEnvironmentBlock {
name: String,
assignments: Vec<RawScalarAssignment>,
dotenv_directives: Vec<RawDotenvDirective>,
span: SourceSpan,
}
pub fn parse_collection(
input: &str,
working_dir: PathBuf,
) -> Result<Collection, pest::error::Error<Rule>> {
parse_collection_with_environment(input, working_dir, None)
}
pub fn parse_collection_with_environment(
input: &str,
working_dir: PathBuf,
selected_environment: Option<&str>,
) -> Result<Collection, pest::error::Error<Rule>> {
let preprocessed = preprocessor::preprocess(input, &working_dir)
.map_err(|err| preprocess_error_to_span(err, pest::Span::new(input, 0, input.len()).unwrap()))?;
let preprocessed = normalize_legacy_collection_header(preprocessed.as_str());
log::debug!("PREPROCESSED COMPLETE:\n{}", preprocessed);
let collection = parse_request_collection(preprocessed.as_str())?;
let mut name = String::new();
let mut description = String::new();
let mut request_templates: Vec<RequestTemplate> = Vec::new();
let mut raw_global_variables = Vec::new();
let mut pre_dotenv_guard_store = VariableStore::new();
let mut top_level_dotenv_directives = Vec::new();
let mut selected_environment_dotenv_directives = Vec::new();
let mut selected_environment_override_keys = HashSet::new();
let mut global_headers: HashMap<String, String> = HashMap::new();
let mut global_queries: Vec<(String, String)> = Vec::new();
let mut global_callbacks: Vec<String> = vec![];
let mut global_cookies: HashMap<String, String> = HashMap::new();
let mut global_reliability = RequestReliabilityPolicyTemplate::default();
let mut schema_registry = SchemaRegistry::default();
let mut declaration_spans = DeclarationSpanIndex::new();
let mut session_targets: HashMap<String, SessionRequestTarget> = HashMap::new();
let mut environments: HashMap<String, RawEnvironmentBlock> = HashMap::new();
let mut oauth_profiles: HashMap<String, OAuthProfile> = HashMap::new();
let mut redaction_rules = RedactionRules::default();
let mut secret_cache = SecretValueCache::default();
for pair in collection.into_inner() {
match pair.as_rule() {
Rule::collection_name => {
name = pair.as_str().trim().to_string();
}
Rule::collection_description => {
description.push_str(pair.as_str().trim());
}
Rule::variable => {
let span = pair.as_span();
let assignment = parse_scalar_assignment(pair);
register_pre_dotenv_guard_variable(
&mut pre_dotenv_guard_store,
assignment.key.clone(),
assignment.value.as_str(),
)
.map_err(|err| template_error_to_span(err, span))?;
raw_global_variables.push(assignment);
}
Rule::dotenv_directive => {
let directive = parse_dotenv_directive(pair)?;
if dotenv_guard_allows(
&directive,
&pre_dotenv_guard_store.clone_scalars(),
&schema_registry,
preprocessed.as_str(),
)? {
top_level_dotenv_directives.push(directive);
}
}
Rule::environment_block => {
let raw_block = parse_environment_block(pair)?;
let span = raw_block.span.into_pest_span(preprocessed.as_str());
if environments
.insert(raw_block.name.clone(), raw_block.clone())
.is_some()
{
return Err(custom_error(
format!("Duplicate environment '{}' declared.", raw_block.name),
span,
));
}
if selected_environment == Some(raw_block.name.as_str()) {
for assignment in &raw_block.assignments {
selected_environment_override_keys.insert(assignment.key.clone());
}
for directive in &raw_block.dotenv_directives {
if dotenv_guard_allows(
directive,
&pre_dotenv_guard_store.clone_scalars(),
&schema_registry,
preprocessed.as_str(),
)? {
selected_environment_dotenv_directives.push(directive.clone());
}
}
}
}
Rule::oauth_block => {
let span = pair.as_span();
let profile = parse_oauth_block(pair)?;
if oauth_profiles
.insert(profile.name.clone(), profile.clone())
.is_some()
{
return Err(custom_error(
format!("Duplicate OAuth profile '{}' declared.", profile.name),
span,
));
}
}
Rule::header => {
let span = pair.as_span();
let mut inner_pairs = pair.into_inner();
let key = inner_pairs.next().unwrap().as_str().trim().to_string();
let value = inner_pairs.next().unwrap().as_str().trim().to_string();
let value = context::try_inject_from_prompt(&value)
.map_err(|err| prompt_error_to_span(err, span.clone()))?;
global_headers.insert(key, value);
}
Rule::query => {
let span = pair.as_span();
let mut inner_pairs = pair.into_inner();
let key = inner_pairs.next().unwrap().as_str().trim().to_string();
let value = inner_pairs.next().unwrap().as_str().trim().to_string();
let value = context::try_inject_from_prompt(&value)
.map_err(|err| prompt_error_to_span(err, span.clone()))?;
global_queries.push((key, value));
}
Rule::cookie => {
let span = pair.as_span();
let mut inner_pairs = pair.into_inner();
let key = inner_pairs.next().unwrap().as_str().trim().to_string();
let value = inner_pairs.next().unwrap().as_str().trim().to_string();
let value = context::try_inject_from_prompt(&value)
.map_err(|err| prompt_error_to_span(err, span.clone()))?;
global_cookies.insert(key, value);
}
Rule::callback => {
global_callbacks.push(pair.as_str().strip_prefix('!').unwrap().to_string());
}
Rule::timeout_directive => {
let span = pair.as_span();
let mut inner_pairs = pair.into_inner();
let value = inner_pairs.next().unwrap().as_str().trim().to_string();
global_reliability.timeout = Some(
context::try_inject_from_prompt(&value)
.map_err(|err| prompt_error_to_span(err, span.clone()))?,
);
}
Rule::poll_until_directive => {
let span = pair.as_span();
let mut inner_pairs = pair.into_inner();
let value = inner_pairs.next().unwrap().as_str().trim().to_string();
global_reliability.poll_until = Some(
context::try_inject_from_prompt(&value)
.map_err(|err| prompt_error_to_span(err, span.clone()))?,
);
}
Rule::poll_every_directive => {
let span = pair.as_span();
let mut inner_pairs = pair.into_inner();
let value = inner_pairs.next().unwrap().as_str().trim().to_string();
global_reliability.poll_every = Some(
context::try_inject_from_prompt(&value)
.map_err(|err| prompt_error_to_span(err, span.clone()))?,
);
}
Rule::redact_header_directive
| Rule::redact_capture_directive
| Rule::redact_body_directive => {
apply_redaction_directive(&mut redaction_rules, pair)?;
}
Rule::declaration => {
remember_declaration_span(&mut declaration_spans, &pair);
register_declaration(&mut schema_registry, pair)?;
}
Rule::requests => {
validate_schema_registry(
&schema_registry,
preprocessed.as_str(),
&declaration_spans,
)?;
if let Some(selected_environment) = selected_environment {
if !environments.contains_key(selected_environment) {
return Err(build_unknown_environment_error(
selected_environment,
&environments,
preprocessed.as_str(),
));
}
}
let dotenv_values = load_dotenv_values(
&top_level_dotenv_directives,
&selected_environment_dotenv_directives,
&working_dir,
preprocessed.as_str(),
)?;
secret_cache.set_dotenv_values(dotenv_values.clone());
for profile in oauth_profiles.values_mut() {
profile.dotenv_values = dotenv_values.clone();
}
let mut global_store = VariableStore::new();
for assignment in &raw_global_variables {
let span = assignment.span.into_pest_span(preprocessed.as_str());
if selected_environment_override_keys.contains(&assignment.key) {
assign_global_variable_for_selected_environment_override(
&mut global_store,
assignment.key.clone(),
assignment.value.as_str(),
);
} else {
assign_global_variable(
&mut global_store,
assignment.key.clone(),
assignment.value.as_str(),
&working_dir,
&mut secret_cache,
)
.map_err(|err| template_error_to_span(err, span))?;
}
}
let mut effective_global_store = global_store.clone();
if let Some(selected_environment) = selected_environment {
let environment = environments.get(selected_environment).ok_or_else(|| {
build_unknown_environment_error(
selected_environment,
&environments,
preprocessed.as_str(),
)
})?;
for assignment in &environment.assignments {
let span = assignment.span.into_pest_span(preprocessed.as_str());
assign_environment_variable(
&mut effective_global_store,
&environment.name,
assignment.key.clone(),
assignment.value.as_str(),
&working_dir,
&mut secret_cache,
)
.map_err(|err| template_error_to_span(err, span))?;
}
}
for request_pair in pair.into_inner() {
let template = parse_request_template(
request_pair,
&effective_global_store,
&oauth_profiles,
&global_headers,
&global_queries,
&global_cookies,
&global_callbacks,
&global_reliability,
&schema_registry,
&redaction_rules,
&working_dir,
&mut secret_cache,
&mut session_targets,
)?;
request_templates.push(template);
}
}
Rule::EOI => {}
_ => {
unreachable!("unexpected rule: {:?}", pair.as_rule());
}
}
}
if let Some(selected_environment) = selected_environment {
if !environments.contains_key(selected_environment) {
return Err(build_unknown_environment_error(
selected_environment,
&environments,
preprocessed.as_str(),
));
}
}
let requests = expand_templates(request_templates)
.map_err(|err| template_error_to_collection_error(err, preprocessed.as_str()))?;
let mut available_environments = environments.keys().cloned().collect::<Vec<_>>();
available_environments.sort();
Ok(Collection {
name,
description,
available_environments,
selected_environment: selected_environment.map(str::to_string),
oauth_profiles,
schema_registry,
requests,
})
}
fn parse_scalar_assignment(pair: pest::iterators::Pair<Rule>) -> RawScalarAssignment {
let span = pair.as_span();
let mut inner_pairs = pair.into_inner();
RawScalarAssignment {
key: inner_pairs
.next()
.expect("scalar assignment should include a key")
.as_str()
.trim()
.to_string(),
value: inner_pairs
.next()
.expect("scalar assignment should include a value")
.as_str()
.to_string(),
span: SourceSpan::from_pest_span(span),
}
}
fn parse_environment_block(
pair: pest::iterators::Pair<Rule>,
) -> Result<RawEnvironmentBlock, pest::error::Error<Rule>> {
let span = pair.as_span();
let mut inner_pairs = pair.into_inner();
let name = inner_pairs
.next()
.expect("environment block should include a name")
.as_str()
.trim()
.to_string();
let mut assignments = Vec::new();
let mut dotenv_directives = Vec::new();
for item in inner_pairs {
match item.as_rule() {
Rule::env_variable => assignments.push(parse_scalar_assignment(item)),
Rule::env_dotenv_directive => dotenv_directives.push(parse_dotenv_directive(item)?),
_ => unreachable!("unexpected environment item: {:?}", item.as_rule()),
}
}
if assignments.is_empty() && dotenv_directives.is_empty() {
return Err(custom_error(
"Environment blocks must declare at least one variable override or dotenv directive."
.to_string(),
span,
));
}
Ok(RawEnvironmentBlock {
name,
assignments,
dotenv_directives,
span: SourceSpan::from_pest_span(span),
})
}
fn parse_dotenv_directive(
pair: pest::iterators::Pair<Rule>,
) -> Result<RawDotenvDirective, pest::error::Error<Rule>> {
let span = pair.as_span();
let raw = pair.as_str().to_string();
let path = pair
.into_inner()
.find(|inner| inner.as_rule() == Rule::dotenv_path)
.expect("dotenv directive should include a path")
.as_str()
.trim()
.to_string();
Ok(RawDotenvDirective {
path: normalize_dotenv_path(path.as_str(), span.clone())?,
guard: parse_dotenv_guard(raw.as_str()),
span: SourceSpan::from_pest_span(span),
})
}
fn normalize_dotenv_path(
raw_path: &str,
span: pest::Span<'_>,
) -> Result<String, pest::error::Error<Rule>> {
let trimmed = raw_path.trim();
if trimmed.is_empty() {
return Err(custom_error("dotenv directive requires a non-empty path".to_string(), span));
}
if trimmed.contains("{{") || trimmed.contains("[[") {
return Err(custom_error(
"dotenv directive paths do not support interpolation".to_string(),
span,
));
}
let normalized = strip_matching_quotes(trimmed);
if normalized.is_empty() {
return Err(custom_error("dotenv directive requires a non-empty path".to_string(), span));
}
Ok(normalized.to_string())
}
fn strip_matching_quotes(raw: &str) -> &str {
if raw.len() >= 2 {
let bytes = raw.as_bytes();
if (bytes[0] == b'"' && bytes[raw.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[raw.len() - 1] == b'\'')
{
return &raw[1..raw.len() - 1];
}
}
raw
}
fn parse_dotenv_guard(raw: &str) -> Option<String> {
let trimmed = raw.trim_start();
if !trimmed.starts_with('[') {
return None;
}
let mut chars = trimmed.chars().peekable();
if chars.next() != Some('[') {
return None;
}
let mut guard = String::new();
let mut in_single = false;
let mut in_double = false;
while let Some(ch) = chars.next() {
match ch {
'\\' => {
if let Some(next) = chars.next() {
guard.push(next);
}
}
'\'' if !in_double => {
in_single = !in_single;
guard.push(ch);
}
'"' if !in_single => {
in_double = !in_double;
guard.push(ch);
}
']' if !in_single && !in_double => return Some(guard.trim().to_string()),
_ => guard.push(ch),
}
}
None
}
fn dotenv_guard_allows(
directive: &RawDotenvDirective,
env: &HashMap<String, String>,
schema_registry: &SchemaRegistry,
source: &str,
) -> Result<bool, pest::error::Error<Rule>> {
let Some(guard) = directive.guard.as_deref() else {
return Ok(true);
};
let span = directive.span.into_pest_span(source);
let raw = format!("[{}] ^ true == true", guard);
let assertion = Assertion::parse_with_registry(raw.as_str(), env, schema_registry)
.map_err(|err| custom_error(format!("invalid dotenv guard '[{}]': {}", guard, err), span.clone()))?;
assertion
.should_execute(env, &ResponseSnapshot::default(), &HashMap::new())
.map_err(|err| custom_error(format!("invalid dotenv guard '[{}]': {}", guard, err), span))
}
fn load_dotenv_values(
top_level: &[RawDotenvDirective],
selected_environment: &[RawDotenvDirective],
working_dir: &PathBuf,
source: &str,
) -> Result<HashMap<String, String>, pest::error::Error<Rule>> {
let mut values = HashMap::new();
let mut seen_paths = HashSet::new();
for directive in top_level.iter().chain(selected_environment.iter()) {
let resolved = resolve_dotenv_path(working_dir, directive.path.as_str());
if !seen_paths.insert(resolved.clone()) {
log::warn!(
"Skipping duplicate dotenv path '{}' (normalized as '{}')",
directive.path,
resolved.display()
);
continue;
}
match fs::read_to_string(&resolved) {
Ok(contents) => {
let parsed = parse_dotenv_contents(contents.as_str(), &resolved)
.map_err(|message| custom_error(message, directive.span.into_pest_span(source)))?;
values.extend(parsed);
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
log::warn!(
"Skipping missing dotenv file '{}' resolved to '{}'",
directive.path,
resolved.display()
);
}
Err(err) => {
return Err(custom_error(
format!("Failed to read dotenv file '{}': {}", resolved.display(), err),
directive.span.into_pest_span(source),
));
}
}
}
Ok(values)
}
fn parse_dotenv_contents(
contents: &str,
resolved_path: &Path,
) -> Result<HashMap<String, String>, String> {
let mut values = HashMap::new();
for (index, line) in contents.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let candidate = trimmed
.strip_prefix("export ")
.map(str::trim_start)
.unwrap_or(trimmed);
let Some((key, raw_value)) = candidate.split_once('=') else {
return Err(format!(
"Invalid dotenv entry on line {} of '{}'. Expected KEY=value.",
index + 1,
resolved_path.display()
));
};
let key = key.trim();
if key.is_empty() || key.chars().any(char::is_whitespace) {
return Err(format!(
"Invalid dotenv key '{}' on line {} of '{}'.",
key,
index + 1,
resolved_path.display()
));
}
values.insert(key.to_string(), parse_dotenv_value(raw_value));
}
Ok(values)
}
fn parse_dotenv_value(raw_value: &str) -> String {
strip_matching_quotes(raw_value.trim()).to_string()
}
fn environment_names(environments: &HashMap<String, RawEnvironmentBlock>) -> String {
let mut names = environments.keys().cloned().collect::<Vec<_>>();
names.sort();
if names.is_empty() {
"<none>".to_string()
} else {
names.join(", ")
}
}
fn build_unknown_environment_error(
selected_environment: &str,
environments: &HashMap<String, RawEnvironmentBlock>,
source: &str,
) -> pest::error::Error<Rule> {
custom_error(
format!(
"Unknown environment '{}'. Available environments: {}.",
selected_environment,
environment_names(environments)
),
pest::Span::new(source, 0, source.len()).expect("source span should exist"),
)
}
fn custom_error(message: String, span: pest::Span<'_>) -> pest::error::Error<Rule> {
pest::error::Error::new_from_span(
pest::error::ErrorVariant::CustomError { message },
span,
)
}