alef 0.25.37

Opinionated polyglot binding generator for Rust libraries
Documentation
use super::*;

#[test]
fn test_generate_docs_empty_api() {
    let api = ApiSurface {
        crate_name: "test".to_string(),
        version: "0.1.0".to_string(),
        types: vec![],
        functions: vec![],
        enums: vec![],
        errors: vec![],
        excluded_type_paths: ::std::collections::HashMap::new(),
        excluded_trait_names: ::std::collections::HashSet::new(),
        services: vec![],
        handler_contracts: vec![],
        unsupported_public_items: Vec::new(),
    };
    let config = make_test_config();

    let files = generate_docs(&api, &config, &[Language::Python], "docs").unwrap();
    // 1 lang + configuration.md + types.md + errors.md
    assert_eq!(files.len(), 4);
    let lang_file = files
        .iter()
        .find(|f| f.path.to_str().unwrap().contains("api-python"))
        .unwrap();
    assert!(lang_file.content.contains("Python API Reference"));
    assert!(lang_file.content.contains("v0.1.0"));
}

#[test]
fn test_generate_docs_respects_language_excludes() {
    let config = config_from_toml(
        r#"
[workspace]
languages = ["python", "go"]

[[crates]]
name = "mylib"
sources = ["src/lib.rs"]

[crates.python]
exclude_functions = ["interact"]
exclude_types = ["InteractionResult"]

[crates.ffi]
exclude_functions = ["ffi_only"]
exclude_types = ["FfiHidden"]
"#,
    );
    let mut api = make_minimal_api("1.2.3");
    api.functions = vec![
        make_function("interact", vec![], TypeRef::Unit, false, None),
        make_function("scrape", vec![], TypeRef::Unit, false, None),
        make_function("ffi_only", vec![], TypeRef::Unit, false, None),
    ];
    api.types = vec![empty_type("InteractionResult"), empty_type("FfiHidden")];

    let files = generate_docs(&api, &config, &[Language::Python, Language::Go], "out").unwrap();
    let python = files
        .iter()
        .find(|f| f.path.to_str().unwrap().contains("api-python"))
        .unwrap();
    let go = files
        .iter()
        .find(|f| f.path.to_str().unwrap().contains("api-go"))
        .unwrap();

    assert!(!python.content.contains("interact()"));
    assert!(python.content.contains("scrape()"));
    assert!(!python.content.contains("InteractionResult"));
    assert!(!go.content.contains("ffi_only()"));
    assert!(!go.content.contains("FfiHidden"));
    assert!(go.content.contains("Interact()"));
}

#[test]
fn test_generate_docs_produces_one_file_per_language_plus_three_shared() {
    let api = make_minimal_api("1.2.3");
    let config = make_test_config();
    let files = generate_docs(&api, &config, &[Language::Python, Language::Node], "out").unwrap();
    // 2 language files + configuration.md + types.md + errors.md
    assert_eq!(files.len(), 5);
    let paths: Vec<&str> = files.iter().map(|f| f.path.to_str().unwrap()).collect();
    assert!(paths.iter().any(|p| p.contains("api-python")));
    assert!(paths.iter().any(|p| p.contains("api-typescript")));
    assert!(paths.iter().any(|p| p.contains("configuration")));
    assert!(paths.iter().any(|p| p.contains("types")));
    assert!(paths.iter().any(|p| p.contains("errors")));
}

#[test]
fn test_generate_docs_all_output_files_end_with_newline() {
    let api = make_minimal_api("0.1.0");
    let config = make_test_config();
    let files = generate_docs(&api, &config, &[Language::Python], "out").unwrap();
    for file in &files {
        assert!(
            file.content.ends_with('\n'),
            "file {:?} must end with trailing newline",
            file.path
        );
    }
}

#[test]
fn test_generate_docs_output_dir_prefix_in_all_paths() {
    let api = make_minimal_api("0.1.0");
    let config = make_test_config();
    let files = generate_docs(&api, &config, &[Language::Python], "custom/output/dir").unwrap();
    for file in &files {
        assert!(
            file.path.to_str().unwrap().starts_with("custom/output/dir"),
            "all paths must be under output_dir: {:?}",
            file.path
        );
    }
}

#[test]
fn test_generate_docs_multiple_languages_produce_correct_slugs() {
    let api = make_minimal_api("0.1.0");
    let config = make_test_config();
    let langs = [
        Language::Python,
        Language::Node,
        Language::Go,
        Language::Java,
        Language::Ruby,
    ];
    let expected_slugs = ["api-python", "api-typescript", "api-go", "api-java", "api-ruby"];
    let files = generate_docs(&api, &config, &langs, "docs/api").unwrap();
    // 5 lang files + 3 shared
    assert_eq!(files.len(), 8);
    for slug in &expected_slugs {
        assert!(
            files.iter().any(|f| f.path.to_str().unwrap().contains(slug)),
            "expected file with slug {slug}"
        );
    }
}

#[test]
fn streaming_adapter_docs_use_language_native_stream_types() {
    let api = streaming_adapter_api();
    let config = streaming_adapter_config("");
    let files = generate_docs(
        &api,
        &config,
        &[
            Language::Python,
            Language::Node,
            Language::Java,
            Language::KotlinAndroid,
            Language::Zig,
            Language::Go,
            Language::Csharp,
            Language::Swift,
            Language::Dart,
            Language::Php,
            Language::Ruby,
            Language::Elixir,
            Language::Wasm,
            Language::Ffi,
            Language::Rust,
        ],
        "out",
    )
    .unwrap();

    let python = doc_content(&files, "api-python");
    assert!(python.contains("def chat_stream(self, req: ChatCompletionRequest) -> AsyncIterator[ChatCompletionChunk]"));
    assert!(python.contains("async for chunk in stream:"));
    assert!(!python.contains("-> str"));

    let typescript = doc_content(&files, "api-typescript");
    assert!(typescript.contains("chatStream(req: ChatCompletionRequest): Promise<ChatStreamIterator>"));
    assert!(typescript.contains("for await (const chunk of stream)"));
    assert!(!typescript.contains("Promise<string>"));

    let java = doc_content(&files, "api-java");
    assert!(
        java.contains(
            "public java.util.stream.Stream<ChatCompletionChunk> chatStream(ChatCompletionRequest req) throws LiterLlmRsException"
        )
    );
    assert!(java.contains("try (var stream = instance.chatStream(new ChatCompletionRequest()))"));
    assert!(!java.contains("public String chatStream"));

    let kotlin_android = doc_content(&files, "api-kotlin-android");
    assert!(
        kotlin_android
            .contains("fun chatStream(req: ChatCompletionRequest): kotlinx.coroutines.flow.Flow<ChatCompletionChunk>")
    );
    assert!(kotlin_android.contains(".collect { chunk ->"));
    assert!(!kotlin_android.contains("fun chatStream(req: ChatCompletionRequest): String"));

    let zig = doc_content(&files, "api-zig");
    assert!(zig.contains(
        "pub fn chat_stream(self: *DefaultClient, req: []const u8) (LiterLlmError||error{OutOfMemory})!ChatCompletionChunkStream"
    ));
    assert!(zig.contains("while (try stream.next()) |chunk|"));
    assert!(!zig.contains("pub fn chatStream"));
    assert!(!zig.contains("[:0]const u8"));

    let go = doc_content(&files, "api-go");
    assert!(
        go.contains(
            "func (o *DefaultClient) ChatStream(req ChatCompletionRequest) (<-chan ChatCompletionChunk, error)"
        )
    );

    let csharp = doc_content(&files, "api-csharp");
    assert!(csharp.contains(
        "public async IAsyncEnumerable<ChatCompletionChunk> ChatStreamAsync(ChatCompletionRequest req, CancellationToken cancellationToken = default)"
    ));
    assert!(csharp.contains("await foreach (var chunk in instance.ChatStreamAsync(new ChatCompletionRequest()))"));

    let swift = doc_content(&files, "api-swift");
    assert!(
        swift.contains("public func chatStream(_ req: ChatCompletionRequest) async throws -> AsyncThrowingStream<ChatCompletionChunk, Error>")
    );
    assert!(swift.contains("for try await chunk in stream"));

    let dart = doc_content(&files, "api-dart");
    assert!(dart.contains("Stream<ChatCompletionChunk> chatStream(ChatCompletionRequest req)"));
    assert!(dart.contains("await for (final chunk in instance.chatStream(ChatCompletionRequest()))"));

    let php = doc_content(&files, "api-php");
    assert!(php.contains("public function chatStream(ChatCompletionRequest $req): array"));
    assert!(php.contains("foreach ($instance->chatStream(new ChatCompletionRequest()) as $chunk)"));
    assert!(php.contains("var_dump($chunk);"));

    let ruby = doc_content(&files, "api-ruby");
    assert!(ruby.contains("def chat_stream(req)"));
    assert!(ruby.contains("**Returns:** `ChatStreamIterator`"));

    let elixir = doc_content(&files, "api-elixir");
    assert!(elixir.contains("def chat_stream(client, req)"));
    assert!(elixir.contains("**Returns:** `{:ok, Stream.t()}`"));

    let wasm = doc_content(&files, "api-wasm");
    assert!(wasm.contains("chatStream(req: ChatCompletionRequest): Promise<ChatStreamIterator>"));
    assert!(wasm.contains("const chunk = await stream.next();"));

    let ffi = doc_content(&files, "api-c");
    assert!(ffi.contains(
        "struct LITERLLMLiterllmDefaultClientChatStreamStreamHandle * literllm_default_client_chat_stream_start"
    ));

    let rust = doc_content(&files, "api-rust");
    assert!(rust.contains(
        "fn chat_stream(&self, req: ChatCompletionRequest) -> BoxFuture<'_, Result<BoxStream<'static, Result<ChatCompletionChunk>>>>"
    ));
}

#[test]
fn streaming_adapter_docs_respect_skip_languages_canonical_names() {
    let api = streaming_adapter_api();
    let config = streaming_adapter_config("skip_languages = [\"node\"]");
    let files = generate_docs(&api, &config, &[Language::Node, Language::Java], "out").unwrap();

    let typescript = doc_content(&files, "api-typescript");
    assert!(!typescript.contains("chatStream("));
    assert!(!typescript.contains("Promise<ChatStreamIterator>"));

    let java = doc_content(&files, "api-java");
    assert!(
        java.contains(
            "public java.util.stream.Stream<ChatCompletionChunk> chatStream(ChatCompletionRequest req) throws LiterLlmRsException"
        )
    );
}

#[test]
fn streaming_adapter_docs_use_crate_exception_for_short_core_paths() {
    let api = streaming_adapter_api();
    let mut config = streaming_adapter_config("");
    config.adapters[0].core_path = "chat_stream".to_string();
    let files = generate_docs(&api, &config, &[Language::Java], "out").unwrap();

    let java = doc_content(&files, "api-java");
    assert!(
        java.contains(
            "public java.util.stream.Stream<ChatCompletionChunk> chatStream(ChatCompletionRequest req) throws LiterLlmRsException"
        )
    );
    assert!(!java.contains("ChatStreamRsException"));
}

#[test]
fn generated_docs_hide_binding_excluded_members_outside_rust() {
    let mut api = make_minimal_api("1.6.0");
    let mut config_type = empty_type("ClientConfig");
    let mut visible_field = make_field("base_url", TypeRef::String, false, None);
    visible_field.doc = "Public base URL.".to_string();
    let mut rust_only_field = make_field("dispatch", TypeRef::String, false, None);
    rust_only_field.doc = "Rust-only dispatch profile.".to_string();
    rust_only_field.binding_excluded = true;
    rust_only_field.binding_exclusion_reason = Some("alef(skip)".to_string());
    config_type.fields = vec![visible_field, rust_only_field];

    let mut client = empty_type("DefaultClient");
    client.is_opaque = true;
    let visible_method = make_method("create", vec![], TypeRef::String, false, false, None);
    let mut rust_only_method = make_method("from_engine", vec![], TypeRef::String, false, false, None);
    rust_only_method.binding_excluded = true;
    rust_only_method.binding_exclusion_reason = Some("alef(skip)".to_string());
    client.methods = vec![visible_method, rust_only_method];

    api.types = vec![config_type, client];
    let config = streaming_adapter_config("");
    let files = generate_docs(&api, &config, &[Language::Python, Language::Rust], "out").unwrap();

    let python = doc_content(&files, "api-python");
    assert!(python.contains("base_url"));
    assert!(!python.contains("dispatch"));
    assert!(python.contains("create()"));
    assert!(!python.contains("from_engine()"));

    let rust = doc_content(&files, "api-rust");
    assert!(rust.contains("dispatch"));
    assert!(rust.contains("from_engine()"));

    let configuration = doc_content(&files, "configuration");
    assert!(configuration.contains("base_url"));
    assert!(!configuration.contains("dispatch"));

    let types = doc_content(&files, "types");
    assert!(types.contains("base_url"));
    assert!(!types.contains("dispatch"));
}

fn streaming_adapter_config(extra_adapter_fields: &str) -> ResolvedCrateConfig {
    config_from_toml(&format!(
        r#"
[workspace]
languages = ["python", "node", "java", "kotlin_android", "zig", "go", "csharp", "swift", "dart", "php", "ruby", "elixir", "wasm", "ffi", "rust"]

[[crates]]
name = "liter-llm"
sources = ["src/lib.rs"]

[crates.ffi]
prefix = "literllm"

[[crates.adapters]]
name = "chat_stream"
pattern = "streaming"
core_path = "liter_llm::DefaultClient::chat_stream"
owner_type = "DefaultClient"
item_type = "ChatCompletionChunk"
error_type = "LiterLlmError"
request_type = "liter_llm::ChatCompletionRequest"
{extra_adapter_fields}

[[crates.adapters.params]]
name = "req"
type = "ChatCompletionRequest"
"#
    ))
}

fn streaming_adapter_api() -> ApiSurface {
    let mut api = make_minimal_api("1.6.0");
    let mut client = empty_type("DefaultClient");
    client.is_opaque = true;
    client.methods = vec![make_method(
        "chat_stream",
        vec![make_param(
            "req",
            TypeRef::Named("ChatCompletionRequest".to_string()),
            false,
        )],
        TypeRef::String,
        true,
        false,
        Some("LiterLlmError"),
    )];
    api.types = vec![
        client,
        empty_type("ChatCompletionRequest"),
        empty_type("ChatCompletionChunk"),
    ];
    api
}

fn doc_content<'a>(files: &'a [crate::core::backend::GeneratedFile], slug: &str) -> &'a str {
    let expected_name = format!("{slug}.md");
    files
        .iter()
        .find(|file| {
            file.path
                .file_name()
                .is_some_and(|name| name.to_string_lossy() == expected_name)
        })
        .map(|file| file.content.as_str())
        .unwrap_or_else(|| panic!("missing generated doc file for {slug}"))
}