ff-sys 0.1.1

Low-level FFmpeg FFI bindings for Rust
//! Build script for ff-sys crate.
//!
//! This script handles:
//! - Platform-specific `FFmpeg` library detection
//! - bindgen code generation for FFI bindings
//!
//! # Windows (VCPKG)
//!
//! Requires FFmpeg installed via VCPKG:
//! ```bash
//! vcpkg install ffmpeg:x64-windows
//! ```
//!
//! Environment variables:
//! - `VCPKG_ROOT`: Path to VCPKG installation (default: `C:\vcpkg`)
//! - `LIBCLANG_PATH`: Path to LLVM/clang bin directory (for bindgen)
//!
//! # macOS (Homebrew)
//!
//! Requires FFmpeg installed via Homebrew:
//! ```bash
//! brew install ffmpeg
//! ```
//!
//! Environment variables:
//! - `HOMEBREW_PREFIX`: Path to Homebrew installation (auto-detected if not set)
//!   - Apple Silicon: `/opt/homebrew`
//!   - Intel: `/usr/local`
//!
//! # Linux (pkg-config)
//!
//! Requires FFmpeg development packages:
//! - Ubuntu/Debian: `apt install libavcodec-dev libavformat-dev libswscale-dev libswresample-dev`
//! - Fedora: `dnf install ffmpeg-devel`
//! - Arch: `pacman -S ffmpeg`

// Build scripts are allowed to use panic/expect for fatal configuration errors
#![allow(clippy::panic)]
#![allow(clippy::expect_used)]

use std::env;
use std::path::{Path, PathBuf};

fn main() {
    // Detect target platform
    let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();

    // Configure platform-specific linking and collect include paths
    let include_paths = match target_os.as_str() {
        "windows" => configure_windows(),
        "macos" => configure_macos(),
        "linux" => configure_linux(),
        other => panic!("Unsupported platform: {other}"),
    };

    // Generate FFI bindings
    generate_bindings(&include_paths);
}

/// FFmpeg libraries required for linking
const FFMPEG_LIBS: &[&str] = &["avformat", "avcodec", "avutil", "swscale", "swresample"];

/// Configure `FFmpeg` linking for Windows via VCPKG.
///
/// Returns include paths for bindgen.
fn configure_windows() -> Vec<String> {
    // Rebuild if environment variables change
    println!("cargo:rerun-if-env-changed=VCPKG_ROOT");
    println!("cargo:rerun-if-env-changed=LIBCLANG_PATH");

    let vcpkg_root = env::var("VCPKG_ROOT").unwrap_or_else(|_| "C:\\vcpkg".to_string());
    let installed_path = Path::new(&vcpkg_root).join("installed").join("x64-windows");

    // Verify VCPKG FFmpeg installation exists
    let lib_path = installed_path.join("lib");
    let include_path = installed_path.join("include");
    let bin_path = installed_path.join("bin");

    if !lib_path.exists() {
        panic!(
            "VCPKG FFmpeg not found at: {}\n\
            Please install FFmpeg via VCPKG:\n\
            vcpkg install ffmpeg:x64-windows",
            lib_path.display()
        );
    }

    // Verify required libraries exist
    for lib in FFMPEG_LIBS {
        let lib_file = lib_path.join(format!("{lib}.lib"));
        if !lib_file.exists() {
            panic!(
                "FFmpeg library not found: {}\n\
                Please reinstall FFmpeg via VCPKG:\n\
                vcpkg install ffmpeg:x64-windows",
                lib_file.display()
            );
        }
    }

    // Set library search path
    println!("cargo:rustc-link-search=native={}", lib_path.display());

    // Link FFmpeg libraries (dynamic linking)
    for lib in FFMPEG_LIBS {
        println!("cargo:rustc-link-lib=dylib={lib}");
    }

    // Set DLL search path for runtime
    // This helps locate FFmpeg DLLs when running the application
    if bin_path.exists() {
        println!("cargo:rustc-env=FFMPEG_DLL_PATH={}", bin_path.display());
    }

    // Configure LLVM/clang path for bindgen
    configure_llvm_for_bindgen();

    vec![include_path.to_string_lossy().into_owned()]
}

/// Configure LLVM/clang path for bindgen on Windows.
///
/// bindgen requires libclang to parse C headers. On Windows, this is typically
/// provided by an LLVM installation.
fn configure_llvm_for_bindgen() {
    // Common LLVM installation paths on Windows
    let llvm_paths = [
        env::var("LIBCLANG_PATH").ok(),
        Some("C:\\Program Files\\LLVM\\bin".to_string()),
        Some("C:\\Program Files (x86)\\LLVM\\bin".to_string()),
        env::var("LLVM_HOME").ok().map(|p| format!("{p}\\bin")),
    ];

    for path in llvm_paths.into_iter().flatten() {
        let clang_dll = Path::new(&path).join("libclang.dll");
        if clang_dll.exists() {
            // Set LIBCLANG_PATH for bindgen
            // SAFETY: This is a build script running in a single-threaded context.
            // Setting environment variables is safe here as no other threads are
            // accessing environment variables concurrently.
            unsafe {
                env::set_var("LIBCLANG_PATH", &path);
            }
            return;
        }
    }

    // If LIBCLANG_PATH is already set, assume it's valid
    if env::var("LIBCLANG_PATH").is_ok() {
        return;
    }

    // Warn but don't fail - bindgen might find it through other means
    println!(
        "cargo:warning=LLVM/clang not found. Set LIBCLANG_PATH environment variable \
         to the LLVM bin directory containing libclang.dll"
    );
}

/// Configure `FFmpeg` linking for macOS via Homebrew.
///
/// This function tries the following detection methods in order:
/// 1. Homebrew installation (Apple Silicon: `/opt/homebrew`, Intel: `/usr/local`)
/// 2. pkg-config as a fallback
///
/// Returns include paths for bindgen.
fn configure_macos() -> Vec<String> {
    println!("cargo:rerun-if-env-changed=HOMEBREW_PREFIX");
    println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH");

    // Try Homebrew first
    if let Some(paths) = try_homebrew() {
        return paths;
    }

    // Fall back to pkg-config
    if let Some(paths) = try_pkgconfig_unix() {
        return paths;
    }

    panic!(
        "FFmpeg not found on macOS.\n\
        Please install FFmpeg via Homebrew:\n\
        brew install ffmpeg\n\n\
        Or ensure pkg-config can find FFmpeg:\n\
        export PKG_CONFIG_PATH=\"/path/to/ffmpeg/lib/pkgconfig\""
    );
}

/// Try to configure FFmpeg via Homebrew.
///
/// Returns include paths if successful, None if FFmpeg is not found.
fn try_homebrew() -> Option<Vec<String>> {
    // Detect Homebrew prefix
    // - Apple Silicon (arm64): /opt/homebrew
    // - Intel (x86_64): /usr/local
    let homebrew_prefix = env::var("HOMEBREW_PREFIX").unwrap_or_else(|_| {
        // Auto-detect based on architecture
        let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
        if arch == "aarch64" {
            "/opt/homebrew".to_string()
        } else {
            "/usr/local".to_string()
        }
    });

    let homebrew_path = Path::new(&homebrew_prefix);
    let lib_path = homebrew_path.join("lib");
    let include_path = homebrew_path.join("include");

    // Verify Homebrew installation exists
    if !lib_path.exists() {
        return None;
    }

    // Verify FFmpeg libraries exist
    let mut all_found = true;
    for lib in FFMPEG_LIBS {
        // Check for .dylib files (macOS dynamic libraries)
        let dylib_file = lib_path.join(format!("lib{lib}.dylib"));
        if !dylib_file.exists() {
            // Also check for .a files (static libraries)
            let static_file = lib_path.join(format!("lib{lib}.a"));
            if !static_file.exists() {
                all_found = false;
                break;
            }
        }
    }

    if !all_found {
        return None;
    }

    // Verify include path contains FFmpeg headers
    let avcodec_header = include_path.join("libavcodec").join("avcodec.h");
    if !avcodec_header.exists() {
        return None;
    }

    // Set library search path
    println!("cargo:rustc-link-search=native={}", lib_path.display());

    // Link FFmpeg libraries (dynamic linking)
    for lib in FFMPEG_LIBS {
        println!("cargo:rustc-link-lib=dylib={lib}");
    }

    Some(vec![include_path.to_string_lossy().into_owned()])
}

/// Configure `FFmpeg` linking for Linux via pkg-config.
///
/// Returns the include paths detected by pkg-config.
fn configure_linux() -> Vec<String> {
    println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH");

    if let Some(paths) = try_pkgconfig_unix() {
        return paths;
    }

    panic!(
        "FFmpeg not found on Linux.\n\
        Please install FFmpeg development packages:\n\n\
        Ubuntu/Debian:\n\
        sudo apt install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev\n\n\
        Fedora:\n\
        sudo dnf install ffmpeg-devel\n\n\
        Arch Linux:\n\
        sudo pacman -S ffmpeg\n\n\
        If FFmpeg is installed in a non-standard location, set PKG_CONFIG_PATH:\n\
        export PKG_CONFIG_PATH=\"/path/to/ffmpeg/lib/pkgconfig\""
    );
}

/// Minimum required FFmpeg version for pkg-config detection.
/// FFmpeg 7.x introduced enum-based SwsFlags (SwsFlags_SWS_*), which is
/// incompatible with the #define macros used in 6.x. Only 7.x is supported.
const FFMPEG_MIN_VERSION: &str = "7.0";

/// pkg-config library names for FFmpeg components.
const PKGCONFIG_LIBS: &[&str] = &[
    "libavformat",
    "libavcodec",
    "libavutil",
    "libswscale",
    "libswresample",
];

/// Try to configure FFmpeg via pkg-config (Unix systems).
///
/// Returns include paths if successful, None if FFmpeg is not found.
fn try_pkgconfig_unix() -> Option<Vec<String>> {
    let mut include_paths = Vec::new();
    let mut all_found = true;

    for lib in PKGCONFIG_LIBS {
        match pkg_config::Config::new()
            .atleast_version(FFMPEG_MIN_VERSION)
            .probe(lib)
        {
            Ok(library) => {
                // Collect include paths from pkg-config
                for path in &library.include_paths {
                    let path_str = path.to_string_lossy().to_string();
                    if !include_paths.contains(&path_str) {
                        include_paths.push(path_str);
                    }
                }
            }
            Err(e) => {
                // Log the error but continue checking other libraries
                println!("cargo:warning=pkg-config: {lib} not found: {e}");
                all_found = false;
                break;
            }
        }
    }

    if all_found { Some(include_paths) } else { None }
}

/// Generate FFI bindings using bindgen.
///
/// # Arguments
/// * `include_paths` - Include paths collected from platform-specific configuration
fn generate_bindings(include_paths: &[String]) {
    // Build bindgen with include paths
    let mut builder = bindgen::Builder::default().header("wrapper.h");

    // Add all include paths
    for path in include_paths {
        builder = builder.clang_arg(format!("-I{path}"));
    }

    let bindings = builder
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        // Allowlist FFmpeg functions
        .allowlist_function("av_.*")
        .allowlist_function("avformat_.*")
        .allowlist_function("avcodec_.*")
        .allowlist_function("sws_.*")
        .allowlist_function("swr_.*")
        // Allowlist FFmpeg types
        .allowlist_type("AV.*")
        .allowlist_type("Sws.*")
        .allowlist_type("Swr.*")
        // Allowlist FFmpeg constants
        .allowlist_var("AV_.*")
        .allowlist_var("AVERROR.*")
        .allowlist_var("AVSEEK_.*")
        .allowlist_var("AVIO_.*")
        .allowlist_var("SWS_.*")
        .allowlist_var("SWR_.*")
        // Derive traits for safety and convenience
        .derive_debug(true)
        .derive_default(true)
        // Disable doc comments - FFmpeg C comments contain invalid Rust code
        .generate_comments(false)
        .generate()
        .expect("Unable to generate bindings");

    let out_path = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set"));
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings!");

    // Rerun build script if wrapper.h changes
    println!("cargo:rerun-if-changed=wrapper.h");
}