hen 0.15.0

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

use pest::Parser;
use pest_derive::Parser;

#[derive(Parser)]
#[grammar = "src/parser/preprocessor.pest"]
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 pairs = PreprocessorParser::parse(Rule::body, input).map_err(Box::new)?;

    let Some(pair) = pairs.next() else {
        return Err(custom_error(input, "Failed to parse preprocessor input"));
    };

    for inner_pair in pair.into_inner() {
        match inner_pair.as_rule() {
            Rule::line => {
                lines.push(inner_pair.as_str().trim().to_string());
            }
            Rule::import_target => {
                let arg = inner_pair.as_str().trim();
                lines.push(resolve_import(arg, working_dir)?);
            }
            _ => {
                return Err(custom_error(
                    inner_pair.as_str(),
                    format!("Unexpected preprocessor rule: {:?}", inner_pair.as_rule()),
                ));
            }
        }
    }

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

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"));
    }
}