use std::{
collections::{BTreeSet, HashMap},
fs,
path::{Path, PathBuf},
sync::{Arc, OnceLock},
};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use crate::{
collection::Collection,
error::{HenError, HenErrorKind, HenResult},
parser::{self, context::{self, PromptMode}, SyntaxSummary},
prompt_generator::scan_directory,
request::{self, ExecutionOptions, ExecutionRecord, 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 dependencies: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct CollectionSummary {
pub path: PathBuf,
pub name: String,
pub description: String,
pub requests: Vec<RequestSummary>,
pub required_inputs: Vec<PromptRequirement>,
}
#[derive(Debug, Clone)]
pub struct RunRequest {
pub path: PathBuf,
pub selector: Option<String>,
pub inputs: HashMap<String, String>,
pub execution_options: ExecutionOptions,
}
#[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 execution_failed: bool,
}
#[derive(Debug, Clone)]
pub struct VerificationResult {
pub path: Option<PathBuf>,
pub summary: SyntaxSummary,
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 _prompt_session = PromptSession::acquire(config.inputs).await?;
let input = load_collection_input(&config.path)?;
let collection = parse_collection_input(&input)?;
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())?;
let execution_result =
request::execute_request_plan(&collection.requests, &plan, config.execution_options).await;
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(RunOutcome {
collection: summarize_collection(&input.path, &input.source, &input.working_dir, &collection)?,
plan,
selected_requests,
primary_target,
records,
failures,
execution_failed,
})
}
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)
}
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(HenError::from)?;
let required_inputs = collect_required_inputs(source, &working_dir)?;
Ok(VerificationResult {
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) -> HenResult<Collection> {
parser::parse_collection(&input.source, input.working_dir.clone())
.map_err(HenError::from)
.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)| RequestSummary {
index,
description: request.description.clone(),
method: request.method.as_str().to_string(),
url: request.url.clone(),
dependencies: request.dependencies.clone(),
})
.collect();
Ok(CollectionSummary {
path: path.to_path_buf(),
name: collection.name.clone(),
description: collection.description.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())
}
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::*;
#[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");
}
}