hen 0.13.3

Run API collections from the command line.
Documentation
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");
    }
}