webfont-generator 0.1.0

Generate webfonts (SVG, TTF, EOT, WOFF, WOFF2) from SVG icons
Documentation
// Integration tests exercise the pure Rust API and CLI. They cannot link against
// the NAPI feature because the test binary is not a Node.js addon.
#![cfg(not(feature = "napi"))]

use std::collections::HashMap;
use std::path::Path;

use webfont_generator::{FontType, GenerateWebfontsOptions};

fn fixture_files() -> Vec<String> {
    let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/svg/fixtures/icons/cleanicons");
    let mut files: Vec<String> = std::fs::read_dir(&dir)
        .expect("fixture dir should exist")
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("svg"))
        .map(|p| p.to_string_lossy().into_owned())
        .collect();
    files.sort();
    files
}

fn temp_dest(prefix: &str) -> String {
    let unique = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    std::env::temp_dir()
        .join(format!("{prefix}-{unique}"))
        .to_string_lossy()
        .into_owned()
}

// --- generate_sync tests ---

#[test]
fn generate_sync_produces_all_default_font_types() {
    let dest = temp_dest("gen-sync-defaults");
    let result = webfont_generator::generate_sync(
        GenerateWebfontsOptions {
            dest: dest.clone(),
            files: fixture_files(),
            write_files: Some(false),
            ..Default::default()
        },
        None,
    )
    .expect("generate_sync should succeed");

    // Default types: eot, woff, woff2
    assert!(result.eot_bytes().is_some(), "should generate EOT");
    assert!(result.woff_bytes().is_some(), "should generate WOFF");
    assert!(result.woff2_bytes().is_some(), "should generate WOFF2");
    // SVG and TTF are not in the default types
    assert!(
        result.svg_string().is_none(),
        "should not generate SVG by default"
    );
    assert!(
        result.ttf_bytes().is_none(),
        "should not generate TTF by default"
    );
}

#[test]
fn generate_sync_produces_requested_types_only() {
    let dest = temp_dest("gen-sync-types");
    let result = webfont_generator::generate_sync(
        GenerateWebfontsOptions {
            dest,
            files: fixture_files(),
            types: Some(vec![FontType::Svg, FontType::Ttf]),
            write_files: Some(false),
            ..Default::default()
        },
        None,
    )
    .expect("generate_sync should succeed");

    assert!(result.svg_string().is_some(), "should generate SVG");
    assert!(result.ttf_bytes().is_some(), "should generate TTF");
    assert!(result.eot_bytes().is_none(), "should not generate EOT");
    assert!(result.woff_bytes().is_none(), "should not generate WOFF");
    assert!(result.woff2_bytes().is_none(), "should not generate WOFF2");
}

#[test]
fn generate_sync_writes_files_to_disk() {
    let dest = temp_dest("gen-sync-write");
    let font_name = "test-icons";
    let result = webfont_generator::generate_sync(
        GenerateWebfontsOptions {
            dest: dest.clone(),
            files: fixture_files(),
            font_name: Some(font_name.to_owned()),
            types: Some(vec![FontType::Woff2]),
            css: Some(true),
            write_files: Some(true),
            ..Default::default()
        },
        None,
    )
    .expect("generate_sync should succeed");

    assert!(result.woff2_bytes().is_some());
    assert!(
        Path::new(&dest).join(format!("{font_name}.woff2")).exists(),
        "WOFF2 file should be written"
    );
    assert!(
        Path::new(&dest).join(format!("{font_name}.css")).exists(),
        "CSS file should be written"
    );

    // Clean up
    let _ = std::fs::remove_dir_all(&dest);
}

#[test]
fn generate_sync_generates_valid_css() {
    let dest = temp_dest("gen-sync-css");
    let result = webfont_generator::generate_sync(
        GenerateWebfontsOptions {
            dest,
            files: fixture_files(),
            font_name: Some("my-icons".to_owned()),
            types: Some(vec![FontType::Woff2]),
            css: Some(true),
            write_files: Some(false),
            ..Default::default()
        },
        None,
    )
    .expect("generate_sync should succeed");

    let css = result
        .generate_css_pure(None)
        .expect("CSS generation should succeed");

    assert!(css.contains("@font-face"), "CSS should contain @font-face");
    assert!(
        css.contains("font-family: \"my-icons\""),
        "CSS should use the configured font name"
    );
    assert!(
        css.contains("format(\"woff2\")"),
        "CSS should reference woff2 format"
    );
}

#[test]
fn generate_sync_generates_html_when_requested() {
    let dest = temp_dest("gen-sync-html");
    let result = webfont_generator::generate_sync(
        GenerateWebfontsOptions {
            dest,
            files: fixture_files(),
            types: Some(vec![FontType::Woff2]),
            html: Some(true),
            write_files: Some(false),
            ..Default::default()
        },
        None,
    )
    .expect("generate_sync should succeed");

    let html = result
        .generate_html_pure(None)
        .expect("HTML generation should succeed");

    assert!(
        html.contains("<!DOCTYPE html>") || html.contains("<html"),
        "should produce HTML"
    );
    assert!(
        html.contains("@font-face"),
        "HTML should embed CSS with @font-face"
    );
}

#[test]
fn generate_sync_applies_rename_callback() {
    let dest = temp_dest("gen-sync-rename");
    let result = webfont_generator::generate_sync(
        GenerateWebfontsOptions {
            dest,
            files: fixture_files(),
            types: Some(vec![FontType::Svg]),
            css: Some(true),
            write_files: Some(false),
            ..Default::default()
        },
        Some(Box::new(|name: &str| format!("prefix-{name}"))),
    )
    .expect("generate_sync should succeed");

    let css = result
        .generate_css_pure(None)
        .expect("CSS generation should succeed");

    assert!(
        css.contains("prefix-"),
        "renamed glyphs should appear in CSS"
    );
}

#[test]
fn generate_sync_rejects_empty_dest() {
    match webfont_generator::generate_sync(
        GenerateWebfontsOptions {
            dest: String::new(),
            files: fixture_files(),
            ..Default::default()
        },
        None,
    ) {
        Err(err) => {
            assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
            assert!(err.to_string().contains("dest"));
        }
        Ok(_) => panic!("should fail with empty dest"),
    }
}

#[test]
fn generate_sync_rejects_empty_files() {
    match webfont_generator::generate_sync(
        GenerateWebfontsOptions {
            dest: "output".to_owned(),
            files: vec![],
            ..Default::default()
        },
        None,
    ) {
        Err(err) => {
            assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
            assert!(err.to_string().contains("files"));
        }
        Ok(_) => panic!("should fail with empty files"),
    }
}

#[test]
fn generate_sync_with_explicit_codepoints() {
    let dest = temp_dest("gen-sync-codepoints");
    let files = fixture_files();
    let first_glyph = Path::new(&files[0])
        .file_stem()
        .unwrap()
        .to_str()
        .unwrap()
        .to_owned();

    let result = webfont_generator::generate_sync(
        GenerateWebfontsOptions {
            dest,
            files,
            types: Some(vec![FontType::Svg]),
            codepoints: Some(HashMap::from([(first_glyph.clone(), 0xE900)])),
            css: Some(true),
            write_files: Some(false),
            ..Default::default()
        },
        None,
    )
    .expect("generate_sync should succeed");

    let css = result
        .generate_css_pure(None)
        .expect("CSS generation should succeed");

    assert!(
        css.contains("e900"),
        "CSS should contain the explicit codepoint"
    );
}

// --- async generate tests ---

#[tokio::test]
async fn generate_async_produces_fonts() {
    let dest = temp_dest("gen-async");
    let result = webfont_generator::generate(
        GenerateWebfontsOptions {
            dest,
            files: fixture_files(),
            types: Some(vec![FontType::Woff2, FontType::Svg]),
            write_files: Some(false),
            ..Default::default()
        },
        None,
    )
    .await
    .expect("async generate should succeed");

    assert!(result.woff2_bytes().is_some());
    assert!(result.svg_string().is_some());
}

// --- CLI tests ---

#[cfg(feature = "cli")]
mod cli {
    use std::process::Command;

    fn cli_bin() -> Command {
        Command::new(env!("CARGO_BIN_EXE_webfont-generator"))
    }

    fn fixture_dir() -> String {
        std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .join("src/svg/fixtures/icons/cleanicons")
            .to_string_lossy()
            .into_owned()
    }

    #[test]
    fn help_flag_succeeds() {
        let output = cli_bin().arg("--help").output().expect("should execute");
        assert!(output.status.success());
        let stdout = String::from_utf8_lossy(&output.stdout);
        assert!(stdout.contains("Generate webfonts from SVG icons"));
    }

    #[test]
    fn version_flag_succeeds() {
        let output = cli_bin().arg("--version").output().expect("should execute");
        assert!(output.status.success());
        let stdout = String::from_utf8_lossy(&output.stdout);
        assert!(stdout.contains("webfont-generator"));
    }

    #[test]
    fn generates_fonts_from_directory() {
        let dest = super::temp_dest("cli-dir");
        let output = cli_bin()
            .args(["--dest", &dest, "--types", "woff2", &fixture_dir()])
            .output()
            .expect("should execute");

        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        assert!(
            output.status.success(),
            "CLI should succeed. stdout: {stdout}, stderr: {stderr}"
        );
        assert!(stdout.contains("WOFF2"), "should report generated WOFF2");
        assert!(
            std::path::Path::new(&dest).join("iconfont.woff2").exists(),
            "WOFF2 file should be written"
        );

        let _ = std::fs::remove_dir_all(&dest);
    }

    #[test]
    fn fails_with_no_files() {
        let output = cli_bin()
            .args(["--dest", "/tmp/empty", "/nonexistent/path"])
            .output()
            .expect("should execute");

        assert!(!output.status.success());
    }

    #[test]
    fn generates_css_and_html() {
        let dest = super::temp_dest("cli-css-html");
        let output = cli_bin()
            .args([
                "--dest",
                &dest,
                "--types",
                "woff2",
                "--html",
                "--font-name",
                "test-font",
                &fixture_dir(),
            ])
            .output()
            .expect("should execute");

        assert!(output.status.success(), "CLI should succeed");
        assert!(
            std::path::Path::new(&dest).join("test-font.css").exists(),
            "CSS file should be written"
        );
        assert!(
            std::path::Path::new(&dest).join("test-font.html").exists(),
            "HTML file should be written"
        );

        let _ = std::fs::remove_dir_all(&dest);
    }
}