alef 0.60.0

Opinionated polyglot binding generator for Rust libraries
Documentation
use crate::e2e::config::E2eConfig;
use crate::e2e::field_access::FieldResolver;
use crate::e2e::fixture::{Fixture, FixtureDocsOperation};

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub(crate) struct PresentationOperation {
    pub(crate) kind: &'static str,
    pub(crate) expression: String,
    pub(crate) item: String,
    pub(crate) fields: Vec<String>,
    pub(crate) optional: bool,
    pub(crate) display: bool,
    pub(crate) destructure_source: String,
    pub(crate) destructure_item: String,
}

pub(crate) fn resolve(fixture: &Fixture, e2e_config: &E2eConfig, language: &str) -> Vec<PresentationOperation> {
    let Some(presentation) = fixture.docs.as_ref().and_then(|docs| docs.presentation.as_ref()) else {
        return Vec::new();
    };
    let call = e2e_config.resolve_call_for_fixture(
        fixture.call.as_deref(),
        &fixture.id,
        &fixture.resolved_category(),
        &fixture.tags,
        &fixture.input,
    );
    let resolver = FieldResolver::new(
        e2e_config.effective_fields(call),
        e2e_config.effective_fields_optional(call),
        e2e_config.effective_result_fields(call),
        e2e_config.effective_fields_array(call),
        e2e_config.effective_fields_method_calls(call),
    );
    presentation
        .operations
        .iter()
        .map(|operation| match operation {
            FixtureDocsOperation::Show { path } => PresentationOperation {
                kind: "show",
                expression: resolver.accessor(path, language, &call.result_var),
                item: String::new(),
                fields: Vec::new(),
                optional: false,
                display: false,
                destructure_source: String::new(),
                destructure_item: String::new(),
            },
            FixtureDocsOperation::Iterate {
                path,
                item,
                fields,
                display,
                optional,
            } => {
                let (destructure_source, destructure_item, expression) =
                    typescript_first_item(path, language, &resolver, &call.result_var);
                PresentationOperation {
                    kind: "iterate",
                    expression,
                    item: item.clone(),
                    fields: fields
                        .iter()
                        .map(|field| resolver.accessor(field, language, item))
                        .collect(),
                    optional: *optional,
                    display: *display,
                    destructure_source,
                    destructure_item,
                }
            }
        })
        .collect()
}

fn typescript_first_item(
    path: &str,
    language: &str,
    resolver: &FieldResolver,
    result_var: &str,
) -> (String, String, String) {
    if matches!(language, "node" | "wasm")
        && let Some((source, tail)) = path.split_once("[0].")
    {
        let source = resolver.accessor(source, language, result_var);
        return (format!("{source} ?? []"), "first".into(), format!("first?.{tail}"));
    }
    (
        String::new(),
        String::new(),
        resolver.accessor(path, language, result_var),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::e2e::config::{ArgMapping, CallConfig};
    use crate::e2e::fixture::{FixtureDocs, FixtureDocsPresentation, SideEffectClass};
    use std::collections::BTreeMap;

    fn fixture() -> Fixture {
        Fixture {
            id: "present_items".into(),
            description: "Present returned items".into(),
            input: serde_json::json!({"old_source": "test.txt"}),
            docs: Some(FixtureDocs {
                topic: "configuration".into(),
                stem: None,
                paths: BTreeMap::new(),
                title: None,
                description: None,
                presentation: Some(FixtureDocsPresentation {
                    call: None,
                    input: Some(serde_json::json!({"source": "guide.txt"})),
                    args: Some(vec![ArgMapping {
                        name: "source".into(),
                        field: "source".into(),
                        arg_type: "string".into(),
                        optional: false,
                        owned: false,
                        element_type: None,
                        go_type: None,
                        vec_inner_is_ref: false,
                        trait_name: None,
                    }]),
                    files: Vec::new(),
                    operations: vec![FixtureDocsOperation::Iterate {
                        path: "items".into(),
                        item: "item".into(),
                        fields: vec!["text".into(), "metadata.heading".into()],
                        display: true,
                        optional: true,
                    }],
                }),
                side_effects: SideEffectClass::Safe,
                coverage_exceptions: BTreeMap::new(),
            }),
            ..Fixture::default()
        }
    }

    fn config() -> E2eConfig {
        E2eConfig {
            call: CallConfig {
                function: "process".into(),
                result_var: "result".into(),
                ..CallConfig::default()
            },
            fields_optional: ["items".to_string()].into_iter().collect(),
            ..E2eConfig::default()
        }
    }

    #[test]
    fn docs_call_overrides_reuse_typed_fixture_arguments() {
        let fixture = fixture().docs_call_fixture();
        assert_eq!(fixture.input, serde_json::json!({"source": "guide.txt"}));
        assert_eq!(fixture.args[0].arg_type, "string");
        assert_eq!(fixture.args[0].field, "source");
    }

    #[test]
    fn docs_call_fixture_removes_mock_harness_and_uses_an_illustrative_url() {
        let mut fixture = fixture();
        fixture
            .docs
            .as_mut()
            .and_then(|docs| docs.presentation.as_mut())
            .expect("presentation")
            .input = None;
        fixture.input = serde_json::json!({
            "mock_responses": [{"path": "/guide.txt", "status_code": 200}],
            "extract_input": {"kind": "uri", "uri": "$mock_url/guide.txt"}
        });
        fixture.mock_response = Some(crate::e2e::fixture::MockResponse {
            status: 200,
            body: None,
            stream_chunks: None,
            headers: Default::default(),
        });

        let docs_fixture = fixture.docs_call_fixture();

        assert!(docs_fixture.mock_response.is_none());
        assert!(docs_fixture.input.get("mock_responses").is_none());
        assert_eq!(
            docs_fixture
                .input
                .pointer("/extract_input/uri")
                .and_then(serde_json::Value::as_str),
            Some("https://example.com/guide.txt")
        );
        assert!(!docs_fixture.needs_mock_server());
    }

    #[test]
    fn presentation_templates_emit_idiomatic_python_rust_and_typescript() {
        let fixture = fixture();
        let config = config();
        let python = resolve(&fixture, &config, "python");
        let rust = resolve(&fixture, &config, "rust");
        let mut typescript_fixture = fixture.clone();
        typescript_fixture
            .docs
            .as_mut()
            .and_then(|docs| docs.presentation.as_mut())
            .expect("presentation")
            .operations = vec![FixtureDocsOperation::Iterate {
            path: "results[0].chunks".into(),
            item: "chunk".into(),
            fields: vec!["content".into()],
            display: true,
            optional: true,
        }];
        let typescript = resolve(&typescript_fixture, &config, "node");

        let python_output = crate::e2e::template_env::render(
            "python/snippet_body.py.jinja",
            minijinja::context! { imports => Vec::<String>::new(), body => vec!["result = process()"],
            is_async => false, presentation => python },
        );
        let rust_output = crate::e2e::template_env::render(
            "rust/snippet_body.rs.jinja",
            minijinja::context! { imports => Vec::<String>::new(), body => vec!["let result = process();"],
            is_async => false, presentation => rust },
        );
        let typescript_output = crate::e2e::template_env::render(
            "typescript/snippet_body.jinja",
            minijinja::context! { imports => vec!["process"], module => "@example/library",
            setup_lines => Vec::<String>::new(), client_setup => "", call_expr => "process()",
            result_var => "result", is_async => false, expects_error => false,
            presentation => typescript },
        );

        assert!(
            python_output.contains("for item in result.items or []:"),
            "{python_output}"
        );
        assert!(
            python_output.contains("print(item.metadata.heading)"),
            "{python_output}"
        );
        assert!(
            rust_output.contains("for item in result.items.iter().flatten()"),
            "{rust_output}"
        );
        assert!(
            rust_output.contains("println!(\"{}\", item.metadata.heading);"),
            "{rust_output}"
        );
        assert!(
            typescript_output.contains("const [first] = result.results ?? [];"),
            "{typescript_output}"
        );
        assert!(
            typescript_output.contains("for (const chunk of first?.chunks ?? [])"),
            "{typescript_output}"
        );
        assert!(
            typescript_output.contains("console.log(chunk.content);"),
            "{typescript_output}"
        );
    }
}