hen 0.18.1

Run protocol-aware API request collections from the command line or through MCP.
Documentation
use std::path::Path;

use pest_derive::Parser;

#[derive(Parser)]
#[grammar = "src/parser/preprocessor.pest"]
#[allow(dead_code)]
struct PreprocessorParser;

type PreprocessError = Box<pest::error::Error<Rule>>;

pub fn preprocess(input: &str, working_dir: &Path) -> Result<String, PreprocessError> {
    let mut lines: Vec<String> = vec![];
    let mut pending_assertion_label: Option<String> = None;
    let mut in_body_block = false;

    for raw_line in input.lines() {
        let trimmed = raw_line.trim();

        if let Some(label) = pending_assertion_label.take() {
            if is_assertion_line(trimmed) {
                lines.push(format!("^: {label}"));
            }
        }

        if trimmed.is_empty() {
            continue;
        }

        if trimmed.starts_with("~~~") {
            in_body_block = !in_body_block;
            lines.push(trimmed.to_string());
            continue;
        }

        if in_body_block {
            if trimmed.starts_with('#') {
                continue;
            }

            lines.push(trimmed.to_string());
            continue;
        }

        if let Some(label) = trimmed.strip_prefix('#') {
            let label = label.trim();
            if !label.is_empty() {
                pending_assertion_label = Some(label.to_string());
            }
            continue;
        }

        if let Some(arg) = parse_import_target(trimmed) {
            lines.push(resolve_import(arg.as_str(), working_dir)?);
            continue;
        }

        lines.push(trimmed.to_string());
    }

    Ok(lines.join("\n"))
}

fn is_assertion_line(line: &str) -> bool {
    if line.starts_with('^') {
        return true;
    }

    let Some(remainder) = line.strip_prefix('[') else {
        return false;
    };
    let Some((_, remainder)) = remainder.split_once(']') else {
        return false;
    };

    remainder.trim_start().starts_with('^')
}

fn parse_import_target(line: &str) -> Option<String> {
    if let Some(remainder) = line.strip_prefix("<<") {
        let target = remainder.trim();
        return (!target.is_empty()).then(|| target.to_string());
    }

    let remainder = line.strip_prefix('[')?;
    let (guard, remainder) = remainder.split_once(']')?;
    if guard.trim().is_empty() {
        return None;
    }

    let remainder = remainder.trim_start().strip_prefix("<<")?;
    let target = remainder.trim();
    (!target.is_empty()).then(|| target.to_string())
}

fn resolve_import(path: &str, working_dir: &Path) -> Result<String, PreprocessError> {
    let import_path = working_dir.join(path);
    let file_content = std::fs::read_to_string(&import_path).map_err(|err| {
        custom_error(
            path,
            format!("Failed to read import '{}': {}", import_path.display(), err),
        )
    })?;
    let import_working_dir = import_path
        .parent()
        .map(Path::to_path_buf)
        .unwrap_or_else(|| working_dir.to_path_buf());

    preprocess(&file_content, &import_working_dir)
}

fn custom_error(source: &str, message: impl Into<String>) -> PreprocessError {
    Box::new(pest::error::Error::new_from_pos(
        pest::error::ErrorVariant::CustomError {
            message: message.into(),
        },
        pest::Position::from_start(source),
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn nested_imports_resolve_relative_to_imported_file() {
        let dir = tempdir().expect("tempdir should exist");
        let root = dir.path();
        let nested_dir = root.join("nested");
        std::fs::create_dir_all(&nested_dir).expect("nested dir should exist");
        std::fs::write(root.join("first.hen"), "<< nested/second.hen\n").expect("first import");
        std::fs::write(nested_dir.join("second.hen"), "<< third.hen\n").expect("second import");
        std::fs::write(nested_dir.join("third.hen"), "GET https://example.com\n")
            .expect("third import");

        let output = preprocess("<< first.hen\n", root).expect("preprocess should succeed");

        assert_eq!(output, "GET https://example.com");
    }

    #[test]
    fn missing_import_returns_readable_error() {
        let dir = tempdir().expect("tempdir should exist");
        let err = preprocess("<< missing.hen\n", dir.path()).expect_err("preprocess should fail");

        assert!(err.to_string().contains("Failed to read import"));
        assert!(err.to_string().contains("missing.hen"));
    }

    #[test]
    fn adjacent_comments_become_assertion_labels() {
        let output = preprocess(
            "GET https://example.com\n# The page loads\n^ & status == 200\n",
            Path::new("."),
        )
        .expect("preprocess should succeed");

        assert_eq!(output, "GET https://example.com\n^: The page loads\n^ & status == 200");
    }

    #[test]
    fn comments_only_label_the_immediately_following_assertion_line() {
        let output = preprocess(
            "# Ignored\n\n^ & status == 200\n# Still ignored\nGET https://example.com\n# Applied\n[ status == 200 ] ^ & body.ok == true\n",
            Path::new("."),
        )
        .expect("preprocess should succeed");

        assert_eq!(
            output,
            "^ & status == 200\nGET https://example.com\n^: Applied\n[ status == 200 ] ^ & body.ok == true"
        );
    }
}