use std::{
collections::{BTreeSet, HashMap},
fs,
path::{Path, PathBuf},
sync::{Arc, Mutex, OnceLock},
};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use crate::{
collection::Collection,
error::{HenError, HenErrorKind, HenResult},
parser::{
self,
context::{self, PromptMode},
SyntaxInspectResult, SyntaxSummary,
},
prompt_generator::scan_directory,
request::{
self, ExecutionOptions, ExecutionRecord, FormDataType, Request, RequestFailure,
RequestPlanner,
},
};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct PromptRequirement {
pub name: String,
pub default: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RequestSummary {
pub index: usize,
pub description: String,
pub method: String,
pub url: String,
pub protocol: String,
pub protocol_context: Option<serde_json::Value>,
pub dependencies: Vec<String>,
pub sensitive_values: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct CollectionSummary {
pub path: PathBuf,
pub name: String,
pub description: String,
pub available_environments: Vec<String>,
pub selected_environment: Option<String>,
pub requests: Vec<RequestSummary>,
pub required_inputs: Vec<PromptRequirement>,
}
#[derive(Debug, Clone)]
pub struct RunRequest {
pub path: PathBuf,
pub selector: Option<String>,
pub environment: Option<String>,
pub inputs: HashMap<String, String>,
pub execution_options: ExecutionOptions,
}
pub struct PreparedRun {
_prompt_session: PromptSession,
pub collection: CollectionSummary,
pub requests: Vec<Request>,
pub plan: Vec<usize>,
pub selected_requests: Vec<usize>,
pub primary_target: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct RunOutcome {
pub collection: CollectionSummary,
pub plan: Vec<usize>,
pub selected_requests: Vec<usize>,
pub primary_target: Option<usize>,
pub records: Vec<ExecutionRecord>,
pub failures: Vec<RequestFailure>,
pub trace: Vec<request::ExecutionTraceEntry>,
pub execution_failed: bool,
pub interrupted: Option<request::InterruptSignal>,
}
#[derive(Debug, Clone)]
pub struct VerificationResult {
pub path: Option<PathBuf>,
pub summary: SyntaxSummary,
pub required_inputs: Vec<PromptRequirement>,
}
#[derive(Debug, Clone)]
pub struct VerificationDiagnosticsResult {
pub ok: bool,
pub path: Option<PathBuf>,
pub summary: Option<SyntaxSummary>,
pub required_inputs: Vec<PromptRequirement>,
pub diagnostics: Vec<crate::error::HenDiagnostic>,
pub error: Option<HenError>,
}
#[derive(Debug, Clone)]
pub struct InspectionResult {
pub path: Option<PathBuf>,
pub summary: SyntaxInspectResult,
pub required_inputs: Vec<PromptRequirement>,
}
struct CollectionInput {
path: PathBuf,
source: String,
working_dir: PathBuf,
}
struct PromptSession {
_permit: OwnedSemaphorePermit,
previous_mode: PromptMode,
}
impl PromptSession {
async fn acquire(inputs: HashMap<String, String>) -> HenResult<Self> {
let permit = prompt_gate().clone().acquire_owned().await.map_err(|err| {
HenError::new(HenErrorKind::Execution, "Prompt input gate is unavailable")
.with_detail(err.to_string())
})?;
let previous_mode = context::prompt_mode();
context::set_prompt_mode(PromptMode::NonInteractive);
context::set_prompt_inputs(inputs);
Ok(Self {
_permit: permit,
previous_mode,
})
}
}
impl Drop for PromptSession {
fn drop(&mut self) {
context::set_prompt_inputs(HashMap::new());
context::set_prompt_mode(self.previous_mode);
}
}
pub async fn run_path(config: RunRequest) -> HenResult<RunOutcome> {
let execution_options = config.execution_options.clone();
let prepared = prepare_run_path(config).await?;
let trace = Arc::new(Mutex::new(request::ExecutionTraceCollector::default()));
let observer: request::ExecutionObserver = {
let trace = Arc::clone(&trace);
Arc::new(move |event| {
trace.lock().unwrap().record_event(&event);
})
};
let execution_result = request::execute_request_plan_with_observer(
&prepared.requests,
&prepared.plan,
execution_options,
Some(observer),
)
.await;
let trace = trace.lock().unwrap().snapshot();
let (records, failures, execution_failed) = match execution_result {
Ok(records) => (records, Vec::new(), false),
Err(err) => {
let (failures, completed) = err.into_parts();
(completed, failures, true)
}
};
Ok(finish_run(
prepared,
records,
failures,
trace,
execution_failed,
None,
))
}
pub async fn prepare_run_path(config: RunRequest) -> HenResult<PreparedRun> {
let prompt_session = PromptSession::acquire(config.inputs).await?;
let input = load_collection_input(&config.path)?;
let collection = parse_collection_input(&input, config.environment.as_deref())?;
let planner = RequestPlanner::new(&collection.requests).map_err(|err| {
HenError::new(
HenErrorKind::Planner,
"Failed to build request dependency graph",
)
.with_detail(err.to_string())
})?;
let (plan, selected_requests, primary_target) =
resolve_execution_plan_non_interactive(&collection, &planner, config.selector.as_deref())?;
validate_plan_prompt_inputs(&collection.requests, &plan)?;
let collection_summary = summarize_collection(
&input.path,
&input.source,
&input.working_dir,
&collection,
)?;
Ok(PreparedRun {
_prompt_session: prompt_session,
collection: collection_summary,
requests: collection.requests,
plan,
selected_requests,
primary_target,
})
}
pub fn finish_run(
prepared: PreparedRun,
records: Vec<ExecutionRecord>,
failures: Vec<RequestFailure>,
trace: Vec<request::ExecutionTraceEntry>,
execution_failed: bool,
interrupted: Option<request::InterruptSignal>,
) -> RunOutcome {
RunOutcome {
collection: prepared.collection,
plan: prepared.plan,
selected_requests: prepared.selected_requests,
primary_target: prepared.primary_target,
records,
failures,
trace,
execution_failed,
interrupted,
}
}
pub fn verify_path(path: PathBuf) -> HenResult<VerificationResult> {
let input = load_collection_input(&path)?;
verify_input(Some(input.path.clone()), &input.source, input.working_dir)
}
pub fn verify_source(source: String, working_dir: PathBuf) -> HenResult<VerificationResult> {
verify_input(None, &source, working_dir)
}
pub fn verify_path_diagnostics(path: PathBuf) -> HenResult<VerificationDiagnosticsResult> {
let input = load_collection_input(&path)?;
verify_input_diagnostics(Some(input.path.clone()), &input.source, input.working_dir)
}
pub fn verify_source_diagnostics(
source: String,
working_dir: PathBuf,
) -> HenResult<VerificationDiagnosticsResult> {
verify_input_diagnostics(None, &source, working_dir)
}
pub fn inspect_path(path: PathBuf) -> HenResult<InspectionResult> {
let input = load_collection_input(&path)?;
inspect_input(Some(input.path.clone()), &input.source, input.working_dir)
}
pub fn inspect_source(source: String, working_dir: PathBuf) -> HenResult<InspectionResult> {
inspect_input(None, &source, working_dir)
}
fn verify_input(
path: Option<PathBuf>,
source: &str,
working_dir: PathBuf,
) -> HenResult<VerificationResult> {
let summary = parser::inspect_collection_syntax(source, working_dir.clone())
.map_err(|err| parser::parse_error_to_hen_error(source, &working_dir, err, path.as_deref()))?;
let required_inputs = collect_required_inputs(source, &working_dir)?;
Ok(VerificationResult {
path,
summary,
required_inputs,
})
}
fn verify_input_diagnostics(
path: Option<PathBuf>,
source: &str,
working_dir: PathBuf,
) -> HenResult<VerificationDiagnosticsResult> {
let required_inputs = collect_required_inputs(source, &working_dir).unwrap_or_default();
match parser::inspect_collection_syntax(source, working_dir.clone()) {
Ok(summary) => Ok(VerificationDiagnosticsResult {
ok: true,
path,
summary: Some(summary),
required_inputs,
diagnostics: Vec::new(),
error: None,
}),
Err(err) => {
let parse_error = parser::parse_error_to_hen_error(
source,
&working_dir,
err,
path.as_deref(),
);
Ok(VerificationDiagnosticsResult {
ok: false,
path,
summary: parser::inspect_collection_syntax_tolerant(source, working_dir),
required_inputs,
diagnostics: parse_error.diagnostics().to_vec(),
error: Some(parse_error),
})
}
}
}
fn inspect_input(
path: Option<PathBuf>,
source: &str,
working_dir: PathBuf,
) -> HenResult<InspectionResult> {
let summary = parser::inspect_collection_editor_support(source, working_dir.clone())
.map_err(|err| parser::parse_error_to_hen_error(source, &working_dir, err, path.as_deref()))?;
let required_inputs = collect_required_inputs(source, &working_dir)?;
Ok(InspectionResult {
path,
summary,
required_inputs,
})
}
fn prompt_gate() -> &'static Arc<Semaphore> {
static PROMPT_GATE: OnceLock<Arc<Semaphore>> = OnceLock::new();
PROMPT_GATE.get_or_init(|| Arc::new(Semaphore::new(1)))
}
fn load_collection_input(path: &Path) -> HenResult<CollectionInput> {
let resolved_path = resolve_collection_path(path)?;
let source = fs::read_to_string(&resolved_path).map_err(|err| {
HenError::new(
HenErrorKind::Io,
format!("Failed to read collection {}", resolved_path.display()),
)
.with_detail(err.to_string())
})?;
let working_dir =
resolved_path
.parent()
.map(Path::to_path_buf)
.unwrap_or(std::env::current_dir().map_err(|err| {
HenError::new(HenErrorKind::Io, "Failed to determine current directory")
.with_detail(err.to_string())
})?);
Ok(CollectionInput {
path: resolved_path,
source,
working_dir,
})
}
fn resolve_collection_path(path: &Path) -> HenResult<PathBuf> {
if path.is_dir() {
let hen_files = scan_directory(path.to_path_buf()).map_err(|err| {
err.with_detail(format!("While scanning directory {}", path.display()))
})?;
return match hen_files.len() {
0 => Err(
HenError::new(HenErrorKind::Input, "No .hen files found in directory")
.with_detail(format!("Directory: {}", path.display())),
),
1 => Ok(hen_files[0].clone()),
_ => Err(HenError::new(
HenErrorKind::Input,
"Directory contains multiple .hen files and cannot be resolved non-interactively",
)
.with_detail(format!("Directory: {}", path.display()))
.with_detail("Provide a specific collection file path instead.")),
};
}
Ok(path.to_path_buf())
}
fn parse_collection_input(
input: &CollectionInput,
selected_environment: Option<&str>,
) -> HenResult<Collection> {
parser::parse_collection_with_environment(
&input.source,
input.working_dir.clone(),
selected_environment,
)
.map_err(|err| {
parser::parse_error_to_hen_error(
&input.source,
&input.working_dir,
err,
Some(&input.path),
)
})
.map_err(|err| {
err.with_detail(format!(
"While parsing collection file {}",
input.path.display()
))
})
}
fn summarize_collection(
path: &Path,
source: &str,
working_dir: &PathBuf,
collection: &Collection,
) -> HenResult<CollectionSummary> {
let required_inputs = collect_required_inputs(source, working_dir)?;
let requests = collection
.requests
.iter()
.enumerate()
.map(|(index, request)| {
let http = request.http_operation();
RequestSummary {
index,
description: request.description.clone(),
method: http.method.as_str().to_string(),
url: http.url.clone(),
protocol: request.protocol().as_str().to_string(),
protocol_context: request.protocol_context_json(),
dependencies: request.dependencies.clone(),
sensitive_values: request.sensitive_values.clone(),
}
})
.collect();
Ok(CollectionSummary {
path: path.to_path_buf(),
name: collection.name.clone(),
description: collection.description.clone(),
available_environments: collection.available_environments.clone(),
selected_environment: collection.selected_environment.clone(),
requests,
required_inputs,
})
}
fn collect_required_inputs(
source: &str,
working_dir: &PathBuf,
) -> HenResult<Vec<PromptRequirement>> {
let preprocessed = parser::preprocess_only(source, working_dir).map_err(|err| {
HenError::new(HenErrorKind::Parse, "Failed to preprocess hen file").with_detail(err)
})?;
let mut prompts = BTreeSet::new();
for (name, default) in context::extract_prompt_placeholders(&preprocessed) {
prompts.insert(PromptRequirement { name, default });
}
Ok(prompts.into_iter().collect())
}
pub fn validate_plan_prompt_inputs(requests: &[Request], plan: &[usize]) -> HenResult<()> {
let should_require_inputs = !context::can_prompt_for_inputs();
let mut missing = BTreeSet::new();
for &idx in plan {
let request = requests.get(idx).ok_or_else(|| {
HenError::new(
HenErrorKind::Planner,
"Execution plan referenced an unknown request",
)
.with_detail(format!("Index: {}", idx))
})?;
if should_require_inputs {
collect_request_missing_prompts(request, &mut missing);
}
}
if missing.is_empty() {
return Ok(());
}
let details = missing
.into_iter()
.map(|requirement| match requirement.default {
Some(default) => format!(
"Missing value for prompt '{}' (default: {})",
requirement.name, default
),
None => format!("Missing value for prompt '{}'", requirement.name),
})
.collect::<Vec<_>>()
.join("\n");
Err(HenError::new(
HenErrorKind::Input,
"One or more required prompt inputs were not supplied",
)
.with_detail(details)
.with_exit_code(2))
}
fn collect_request_missing_prompts(request: &Request, missing: &mut BTreeSet<PromptRequirement>) {
let http = request.http_operation();
collect_missing_prompts_from_str(&http.url, missing);
for value in http.headers.values() {
collect_missing_prompts_from_str(value, missing);
}
for value in http.query_params.values() {
collect_missing_prompts_from_str(value, missing);
}
for value in http.form_data.values() {
match value {
FormDataType::Text(text) => collect_missing_prompts_from_str(text, missing),
FormDataType::File(path) => {
let path = path.to_string_lossy();
collect_missing_prompts_from_str(path.as_ref(), missing);
}
}
}
if let Some(body) = &http.body {
collect_missing_prompts_from_str(body, missing);
}
if let Some(content_type) = &http.body_content_type {
collect_missing_prompts_from_str(content_type, missing);
}
if let Some(auth_profile) = &request.auth_profile {
for value in auth_profile.fields.values() {
collect_missing_prompts_from_str(value, missing);
}
for value in auth_profile.params.values() {
collect_missing_prompts_from_str(value, missing);
}
}
if let Some(graphql) = request.graphql_operation() {
if let Some(operation_name) = &graphql.operation_name {
collect_missing_prompts_from_str(operation_name, missing);
}
collect_missing_prompts_from_str(&graphql.document, missing);
if let Some(variables_json) = &graphql.variables_json {
collect_missing_prompts_from_str(variables_json, missing);
}
}
if let Some(mcp) = request.mcp_operation() {
match &mcp.call {
crate::request::McpCall::Initialize(initialize) => {
if let Some(protocol_version) = &initialize.protocol_version {
collect_missing_prompts_from_str(protocol_version, missing);
}
if let Some(client_name) = &initialize.client_name {
collect_missing_prompts_from_str(client_name, missing);
}
if let Some(client_version) = &initialize.client_version {
collect_missing_prompts_from_str(client_version, missing);
}
if let Some(capabilities_json) = &initialize.capabilities_json {
collect_missing_prompts_from_str(capabilities_json, missing);
}
}
crate::request::McpCall::ToolsList | crate::request::McpCall::ResourcesList => {}
crate::request::McpCall::ToolsCall(tool_call) => {
collect_missing_prompts_from_str(&tool_call.tool_name, missing);
if let Some(arguments_json) = &tool_call.arguments_json {
collect_missing_prompts_from_str(arguments_json, missing);
}
}
}
}
}
fn collect_missing_prompts_from_str(value: &str, missing: &mut BTreeSet<PromptRequirement>) {
for (name, default) in context::extract_prompt_placeholders(value) {
if !context::has_prompt_input(&name) {
missing.insert(PromptRequirement { name, default });
}
}
}
fn resolve_execution_plan_non_interactive(
collection: &Collection,
planner: &RequestPlanner,
selector: Option<&str>,
) -> HenResult<(Vec<usize>, Vec<usize>, Option<usize>)> {
match selector {
Some("all") => {
let order = planner.order_all();
Ok((order.clone(), order, None))
}
Some(selector) => {
let idx = selector.parse::<usize>().map_err(|_| {
HenError::new(HenErrorKind::Input, "Selector must be an integer or 'all'")
.with_detail(format!("Received: {}", selector))
.with_exit_code(2)
})?;
let order = planner.order_for(idx).map_err(|err| {
HenError::new(HenErrorKind::Planner, "Failed to plan for selected request")
.with_detail(err.to_string())
})?;
Ok((order.clone(), vec![idx], Some(idx)))
}
None => {
if collection.requests.len() == 1 {
let order = planner.order_for(0).map_err(|err| {
HenError::new(HenErrorKind::Planner, "Failed to plan for the only request")
.with_detail(err.to_string())
})?;
Ok((order.clone(), vec![0], Some(0)))
} else {
Err(HenError::new(
HenErrorKind::Input,
"A selector is required when a collection contains multiple requests",
)
.with_detail("Provide a request index or use 'all'.")
.with_exit_code(2))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::request::{
GraphqlOperation, HttpOperation, McpCall, McpOperation, McpToolCall, RequestOperation,
};
use http::Method;
use std::{collections::HashMap, path::PathBuf};
fn prompt_mode_guard() -> std::sync::MutexGuard<'static, ()> {
context::prompt_state_test_guard()
}
#[test]
fn verify_source_rejects_invalid_assertion_operator() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Broken assertion
GET https://example.com
^ & body =~ /foo/
"#;
let error = verify_source(source.to_string(), working_dir).unwrap_err();
assert_eq!(error.kind(), HenErrorKind::Parse);
assert!(error
.to_string()
.contains("No valid operator found in assertion"));
}
#[test]
fn verify_source_accepts_valid_matches_assertion() {
let working_dir = std::env::current_dir().unwrap();
let source = r#"
---
Valid assertion
GET https://example.com
^ & body ~= /foo/
"#;
let result = verify_source(source.to_string(), working_dir).unwrap();
assert_eq!(result.summary.requests.len(), 1);
assert_eq!(result.summary.requests[0].method, "GET");
assert_eq!(result.summary.requests[0].url, "https://example.com");
}
#[test]
fn validate_plan_prompt_inputs_includes_graphql_document_and_variables() {
let _guard = prompt_mode_guard();
let previous_mode = context::prompt_mode();
context::set_prompt_mode(PromptMode::NonInteractive);
let request = Request {
description: "GraphQL request".into(),
base_description: "GraphQL request".into(),
operation: RequestOperation::Graphql(GraphqlOperation {
http: HttpOperation {
method: Method::POST,
url: "https://example.com/graphql".into(),
headers: HashMap::new(),
query_params: HashMap::new(),
form_data: HashMap::new(),
body: None,
body_content_type: None,
},
operation_name: Some("[[ OP_NAME ]]".into()),
document: "query [[ QUERY_NAME ]] { viewer { id } }".into(),
variables_json: Some(r#"{"id": "[[ USER_ID ]]"}"#.into()),
}),
reliability: crate::request::RequestReliabilityPolicy::default(),
session_name: None,
auth_profile_name: None,
auth_profile: None,
callback_src: vec![],
response_captures: vec![],
assertions: vec![],
declared_dependencies: vec![],
dependencies: vec![],
context: HashMap::new(),
redaction_rules: crate::request::RedactionRules::default(),
sensitive_values: vec![],
working_dir: PathBuf::new(),
map_iteration: None,
};
let err = validate_plan_prompt_inputs(&[request], &[0]).expect_err("should fail");
let message = err.to_string();
assert!(message.contains("OP_NAME"));
assert!(message.contains("QUERY_NAME"));
assert!(message.contains("USER_ID"));
context::set_prompt_mode(previous_mode);
}
#[test]
fn validate_plan_prompt_inputs_includes_mcp_tool_call_fields() {
let _guard = prompt_mode_guard();
let previous_mode = context::prompt_mode();
context::set_prompt_mode(PromptMode::NonInteractive);
let request = Request {
description: "MCP request".into(),
base_description: "MCP request".into(),
operation: RequestOperation::Mcp(McpOperation {
http: HttpOperation {
method: Method::POST,
url: "https://example.com/mcp".into(),
headers: HashMap::new(),
query_params: HashMap::new(),
form_data: HashMap::new(),
body: None,
body_content_type: None,
},
call: McpCall::ToolsCall(McpToolCall {
tool_name: "[[ TOOL_NAME ]]".into(),
arguments_json: Some(r#"{"query": "[[ QUERY ]]"}"#.into()),
}),
}),
reliability: crate::request::RequestReliabilityPolicy::default(),
session_name: Some("app".into()),
auth_profile_name: None,
auth_profile: None,
callback_src: vec![],
response_captures: vec![],
assertions: vec![],
declared_dependencies: vec![],
dependencies: vec![],
context: HashMap::new(),
redaction_rules: crate::request::RedactionRules::default(),
sensitive_values: vec![],
working_dir: PathBuf::new(),
map_iteration: None,
};
let err = validate_plan_prompt_inputs(&[request], &[0]).expect_err("should fail");
let message = err.to_string();
assert!(message.contains("TOOL_NAME"));
assert!(message.contains("QUERY"));
context::set_prompt_mode(previous_mode);
}
#[test]
fn validate_plan_prompt_inputs_allows_missing_values_in_interactive_mode() {
let _guard = prompt_mode_guard();
let previous_mode = context::prompt_mode();
context::set_prompt_mode(PromptMode::Interactive);
context::set_prompt_terminal_override(Some(true));
let request = Request {
description: "Prompted request".into(),
base_description: "Prompted request".into(),
operation: RequestOperation::Http(HttpOperation {
method: Method::POST,
url: "https://example.com/items/[[ ITEM_ID ]]".into(),
headers: HashMap::new(),
query_params: HashMap::new(),
form_data: HashMap::new(),
body: Some("{\"password\": \"[[ password ]]\"}".into()),
body_content_type: Some("application/json".into()),
}),
reliability: crate::request::RequestReliabilityPolicy::default(),
session_name: None,
auth_profile_name: None,
auth_profile: None,
callback_src: vec![],
response_captures: vec![],
assertions: vec![],
declared_dependencies: vec![],
dependencies: vec![],
context: HashMap::new(),
redaction_rules: crate::request::RedactionRules::default(),
sensitive_values: vec![],
working_dir: PathBuf::new(),
map_iteration: None,
};
let result = validate_plan_prompt_inputs(&[request], &[0]);
context::set_prompt_terminal_override(None);
context::set_prompt_mode(previous_mode);
assert!(result.is_ok(), "expected interactive runs to defer prompt collection");
}
}