boltffi_cli 0.24.0

CLI for BoltFFI - generate Swift, Kotlin, and WASM bindings from Rust
use std::path::{Component, Path, PathBuf};

pub(crate) fn configured_build_target(
    cargo_args: &[String],
    working_directory: Option<&Path>,
) -> Option<String> {
    extract_cargo_config_args(cargo_args)
        .into_iter()
        .rev()
        .find_map(|config_argument| {
            parse_build_target_from_inline_config(&config_argument).or_else(|| {
                parse_build_target_from_config_file(&resolve_cargo_config_path(
                    &config_argument,
                    working_directory,
                ))
            })
        })
        .or_else(|| {
            std::env::var("CARGO_BUILD_TARGET")
                .ok()
                .map(|target| target.trim().to_string())
                .filter(|target| !target.is_empty())
        })
        .or_else(|| {
            cargo_config_file_candidates(cargo_args, working_directory)
                .into_iter()
                .find_map(|config_path| parse_build_target_from_config_file(&config_path))
        })
}

pub(crate) fn extract_cargo_config_args(cargo_args: &[String]) -> Vec<String> {
    let mut config_arguments = Vec::new();
    let mut index = 0;

    while index < cargo_args.len() {
        let argument = &cargo_args[index];

        if let Some(value) = argument.strip_prefix("--config=") {
            config_arguments.push(value.to_string());
        } else if argument == "--config"
            && let Some(value) = cargo_args.get(index + 1)
        {
            config_arguments.push(value.clone());
            index += 1;
        }

        index += 1;
    }

    config_arguments
}

pub(crate) fn resolve_cargo_config_path(
    config_argument: &str,
    working_directory: Option<&Path>,
) -> PathBuf {
    let config_path = PathBuf::from(config_argument);

    if config_path.is_absolute() {
        return config_path;
    }

    working_directory
        .map(|working_directory| working_directory.join(&config_path))
        .unwrap_or(config_path)
}

pub(crate) fn cargo_config_file_candidates(
    cargo_args: &[String],
    working_directory: Option<&Path>,
) -> Vec<PathBuf> {
    cargo_config_file_candidates_with_inputs(
        cargo_config_search_roots(cargo_args, working_directory),
        working_directory.map(Path::to_path_buf),
        std::env::var_os("CARGO_HOME").map(PathBuf::from),
        cargo_home_fallback_home_dir(),
    )
}

pub(crate) fn cargo_config_file_candidates_with_inputs(
    search_roots: Vec<PathBuf>,
    working_directory: Option<PathBuf>,
    cargo_home: Option<PathBuf>,
    home_directory: Option<PathBuf>,
) -> Vec<PathBuf> {
    let mut candidates = Vec::new();

    search_roots.into_iter().for_each(|search_root| {
        let mut current_root = search_root;

        loop {
            let new_candidates = [".cargo/config.toml", ".cargo/config"]
                .into_iter()
                .map(|config_name| current_root.join(config_name))
                .filter(|config_path| config_path.exists() && !candidates.contains(config_path))
                .collect::<Vec<_>>();
            candidates.extend(new_candidates);

            if !current_root.pop() {
                break;
            }
        }
    });

    let resolved_cargo_home = cargo_home.map(|cargo_home| {
        if cargo_home.is_absolute() {
            cargo_home
        } else if let Some(working_directory) = working_directory.as_ref() {
            working_directory.join(cargo_home)
        } else {
            cargo_home
        }
    });

    resolved_cargo_home
        .into_iter()
        .chain(
            home_directory
                .into_iter()
                .map(|home_directory| home_directory.join(".cargo")),
        )
        .flat_map(|config_root| {
            ["config.toml", "config"]
                .into_iter()
                .map(move |config_name| config_root.join(config_name))
        })
        .filter(|config_path| config_path.exists() && !candidates.contains(config_path))
        .collect::<Vec<_>>()
        .into_iter()
        .for_each(|config_path| candidates.push(config_path));

    candidates
}

pub(crate) fn cargo_config_search_roots(
    cargo_args: &[String],
    working_directory: Option<&Path>,
) -> Vec<PathBuf> {
    let mut search_roots = Vec::new();

    if let Some(manifest_directory) = selected_manifest_directory(cargo_args, working_directory)
        && !search_roots.contains(&manifest_directory)
    {
        search_roots.push(manifest_directory);
    }

    if let Some(working_directory) = working_directory.map(Path::to_path_buf)
        && !search_roots.contains(&working_directory)
    {
        search_roots.push(working_directory);
    }

    search_roots
}

pub(crate) fn parse_build_target_from_inline_config(config_argument: &str) -> Option<String> {
    config_argument
        .strip_prefix("build.target=")
        .map(trim_wrapping_quotes)
        .filter(|target| !target.is_empty())
        .map(str::to_string)
}

pub(crate) fn parse_build_target_from_config_file(config_path: &Path) -> Option<String> {
    let content = std::fs::read_to_string(config_path).ok()?;
    let value: toml::Value = toml::from_str(&content).ok()?;
    value
        .get("build")?
        .get("target")?
        .as_str()
        .filter(|target| !target.is_empty())
        .map(str::to_string)
}

fn selected_manifest_directory(
    cargo_args: &[String],
    working_directory: Option<&Path>,
) -> Option<PathBuf> {
    let mut index = 0;

    while index < cargo_args.len() {
        let argument = &cargo_args[index];
        let manifest_path = if let Some(path) = argument.strip_prefix("--manifest-path=") {
            Some(path)
        } else if argument == "--manifest-path" {
            cargo_args.get(index + 1).map(|value| value.as_str())
        } else {
            None
        };

        if let Some(manifest_path) = manifest_path {
            let manifest_path = PathBuf::from(manifest_path);
            let resolved_path = if manifest_path.is_absolute() {
                manifest_path
            } else if let Some(working_directory) = working_directory {
                working_directory.join(manifest_path)
            } else {
                manifest_path
            };

            return resolved_path
                .parent()
                .map(normalize_search_path)
                .filter(|path| !path.as_os_str().is_empty());
        }

        if argument == "--manifest-path" {
            index += 1;
        }

        index += 1;
    }

    None
}

fn normalize_search_path(path: &Path) -> PathBuf {
    path.components()
        .fold(PathBuf::new(), |mut normalized_path, component| {
            match component {
                Component::CurDir => {}
                Component::ParentDir => {
                    if normalized_path.file_name().is_some() {
                        normalized_path.pop();
                    } else if !normalized_path.has_root() {
                        normalized_path.push(component.as_os_str());
                    }
                }
                _ => normalized_path.push(component.as_os_str()),
            }

            normalized_path
        })
}

fn cargo_home_fallback_home_dir() -> Option<PathBuf> {
    std::env::var_os("HOME")
        .map(PathBuf::from)
        .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
        .or_else(|| {
            let drive = std::env::var_os("HOMEDRIVE")?;
            let path = std::env::var_os("HOMEPATH")?;
            let mut home_directory = PathBuf::from(drive);
            home_directory.push(path);
            Some(home_directory)
        })
}

fn trim_wrapping_quotes(value: &str) -> &str {
    value
        .strip_prefix('"')
        .and_then(|trimmed| trimmed.strip_suffix('"'))
        .or_else(|| {
            value
                .strip_prefix('\'')
                .and_then(|trimmed| trimmed.strip_suffix('\''))
        })
        .unwrap_or(value)
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::{Path, PathBuf};
    use std::time::{SystemTime, UNIX_EPOCH};

    use super::{
        cargo_config_file_candidates_with_inputs, cargo_config_search_roots,
        configured_build_target, extract_cargo_config_args, parse_build_target_from_config_file,
        parse_build_target_from_inline_config,
    };

    #[test]
    fn extracts_cargo_config_args_from_split_and_inline_flags() {
        let config_args = extract_cargo_config_args(&[
            "--locked".to_string(),
            "--config=build.target='x86_64-unknown-linux-gnu'".to_string(),
            "--config".to_string(),
            "target.x86_64-unknown-linux-gnu.linker='zig'".to_string(),
        ]);

        assert_eq!(
            config_args,
            vec![
                "build.target='x86_64-unknown-linux-gnu'".to_string(),
                "target.x86_64-unknown-linux-gnu.linker='zig'".to_string(),
            ]
        );
    }

    #[test]
    fn parses_build_target_from_inline_cargo_config() {
        assert_eq!(
            parse_build_target_from_inline_config("build.target=\"x86_64-pc-windows-gnu\"")
                .as_deref(),
            Some("x86_64-pc-windows-gnu")
        );
    }

    #[test]
    fn parses_build_target_from_cargo_config_file() {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("time went backwards")
            .as_nanos();
        let temp_directory =
            std::env::temp_dir().join(format!("boltffi-build-target-config-test-{unique}"));
        fs::create_dir_all(&temp_directory).expect("create temp directory");
        let config_path = temp_directory.join("config.toml");

        fs::write(
            &config_path,
            "[build]\ntarget = \"x86_64-unknown-linux-musl\"\n",
        )
        .expect("write config file");

        let target = parse_build_target_from_config_file(&config_path).expect("config target");

        assert_eq!(target, "x86_64-unknown-linux-musl");
    }

    #[test]
    fn searches_manifest_directory_before_current_directory() {
        let search_roots = cargo_config_search_roots(
            &[
                "--manifest-path".to_string(),
                "workspace/member/Cargo.toml".to_string(),
            ],
            Some(Path::new("/tmp/project")),
        );

        assert_eq!(
            search_roots,
            vec![
                PathBuf::from("/tmp/project/workspace/member"),
                PathBuf::from("/tmp/project"),
            ]
        );
    }

    #[test]
    fn includes_home_cargo_config_candidates_after_workspace_candidates() {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("time went backwards")
            .as_nanos();
        let temp_root =
            std::env::temp_dir().join(format!("boltffi-cargo-config-candidates-{unique}"));
        let workspace_directory = temp_root.join("workspace");
        let home_directory = temp_root.join("home");

        fs::create_dir_all(workspace_directory.join(".cargo")).expect("create workspace .cargo");
        fs::create_dir_all(home_directory.join(".cargo")).expect("create home .cargo");
        fs::write(workspace_directory.join(".cargo").join("config.toml"), [])
            .expect("write workspace cargo config");
        fs::write(home_directory.join(".cargo").join("config"), [])
            .expect("write home cargo config");

        let candidates = cargo_config_file_candidates_with_inputs(
            vec![workspace_directory.clone()],
            Some(workspace_directory.clone()),
            None,
            Some(home_directory.clone()),
        );

        assert!(candidates.contains(&workspace_directory.join(".cargo").join("config.toml")));
        assert!(candidates.contains(&home_directory.join(".cargo").join("config")));
    }

    #[test]
    fn resolves_cargo_configured_build_target_from_inline_config() {
        let target = configured_build_target(
            &["--config=build.target='x86_64-pc-windows-gnu'".to_string()],
            Some(Path::new("/tmp/workspace")),
        )
        .expect("configured build target");

        assert_eq!(target, "x86_64-pc-windows-gnu");
    }
}