herolib-code 0.3.13

Code analysis and parsing utilities for Rust source files
Documentation
use crate::rust_builder::error::{BuilderResult, RustBuilderError};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

/// Binary target information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BinaryTarget {
    /// Binary name
    pub name: String,
    /// Path to main.rs or binary source
    pub path: Option<PathBuf>,
}

/// Parsed metadata from Cargo.toml.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CargoMetadata {
    /// Package name
    pub name: String,

    /// Package version
    pub version: String,

    /// List of binary targets
    pub binaries: Vec<BinaryTarget>,

    /// Whether this is a library
    pub has_lib: bool,

    /// Library name (if different from package name)
    pub lib_name: Option<String>,

    /// List of examples
    pub examples: Vec<String>,

    /// Edition (2021, 2024, etc.)
    pub edition: String,

    /// Whether this is a workspace root
    pub is_workspace: bool,

    /// Workspace members (if workspace)
    pub workspace_members: Vec<String>,
}

impl CargoMetadata {
    /// Creates an empty CargoMetadata with defaults
    pub fn empty() -> Self {
        Self {
            name: String::new(),
            version: String::new(),
            binaries: Vec::new(),
            has_lib: false,
            lib_name: None,
            examples: Vec::new(),
            edition: "2021".to_string(),
            is_workspace: false,
            workspace_members: Vec::new(),
        }
    }
}

/// Walks up from path to find Cargo.toml.
pub(crate) fn find_cargo_toml<P: AsRef<Path>>(start: P) -> Option<PathBuf> {
    let mut current = start.as_ref().to_path_buf();

    // If the start path is a file, go to its parent
    if current.is_file() {
        current = current.parent()?.to_path_buf();
    }

    // Walk up the directory tree
    loop {
        let cargo_toml = current.join("Cargo.toml");
        if cargo_toml.exists() {
            return Some(cargo_toml);
        }

        // Move to parent directory
        match current.parent() {
            Some(parent) if parent != current => {
                current = parent.to_path_buf();
            }
            _ => return None,
        }
    }
}

/// Internal TOML structures for parsing
mod toml_models {
    use serde::Deserialize;

    #[derive(Deserialize, Debug)]
    pub struct CargoToml {
        pub package: Option<Package>,
        pub workspace: Option<Workspace>,
        #[serde(default)]
        pub bin: Vec<BinTarget>,
        #[serde(default)]
        pub example: Vec<ExampleTarget>,
        #[serde(default)]
        pub lib: Option<LibTarget>,
    }

    #[derive(Deserialize, Debug)]
    #[serde(untagged)]
    pub enum Version {
        String(String),
        #[serde(rename_all = "lowercase")]
        Workspace { #[allow(dead_code)] workspace: bool },
    }

    #[derive(Deserialize, Debug)]
    #[serde(untagged)]
    pub enum Edition {
        String(String),
        #[serde(rename_all = "lowercase")]
        Workspace { #[allow(dead_code)] workspace: bool },
    }

    #[derive(Deserialize, Debug)]
    pub struct Package {
        pub name: String,
        #[serde(default)]
        pub version: Option<Version>,
        #[serde(default)]
        pub edition: Option<Edition>,
    }

    #[derive(Deserialize, Debug)]
    pub struct Workspace {
        #[serde(default)]
        pub members: Vec<String>,
        #[serde(default)]
        #[allow(dead_code)]
        pub exclude: Vec<String>,
    }

    #[derive(Deserialize, Debug)]
    pub struct LibTarget {
        pub name: Option<String>,
        #[allow(dead_code)]
        pub path: Option<String>,
    }

    #[derive(Deserialize, Debug)]
    pub struct BinTarget {
        pub name: String,
        pub path: Option<String>,
    }

    #[derive(Deserialize, Debug)]
    pub struct ExampleTarget {
        pub name: String,
        #[allow(dead_code)]
        pub path: Option<String>,
    }
}

/// Parses a Cargo.toml file into CargoMetadata.
pub(crate) fn parse_cargo_toml<P: AsRef<Path>>(path: P) -> BuilderResult<CargoMetadata> {
    let path = path.as_ref();

    if !path.exists() {
        return Err(RustBuilderError::PathNotFound {
            path: path.to_path_buf(),
        });
    }

    let content = std::fs::read_to_string(path).map_err(|e| RustBuilderError::Io(e))?;

    let toml_data: toml_models::CargoToml = toml::from_str(&content).map_err(|e| {
        RustBuilderError::CargoTomlParseError {
            path: path.to_path_buf(),
            message: e.to_string(),
        }
    })?;

    let mut metadata = CargoMetadata::empty();

    // Parse package information
    if let Some(package) = toml_data.package {
        metadata.name = package.name;
        metadata.version = match package.version {
            Some(toml_models::Version::String(v)) => v,
            Some(toml_models::Version::Workspace { .. }) => "0.0.0".to_string(), // Default for workspace inheritance
            None => "0.0.0".to_string(),
        };
        metadata.edition = match package.edition {
            Some(toml_models::Edition::String(e)) => e,
            Some(toml_models::Edition::Workspace { .. }) => "2021".to_string(), // Default for workspace inheritance
            None => "2021".to_string(),
        };
    }

    // Check for library at root level
    metadata.has_lib = toml_data.lib.is_some();
    if let Some(lib) = toml_data.lib {
        metadata.lib_name = lib.name;
    }

    // Parse binary targets at root level
    for bin in toml_data.bin {
        metadata.binaries.push(BinaryTarget {
            name: bin.name,
            path: bin.path.map(PathBuf::from),
        });
    }

    // Parse examples at root level
    for example in toml_data.example {
        metadata.examples.push(example.name);
    }

    // Check for workspace
    if let Some(workspace) = toml_data.workspace {
        metadata.is_workspace = true;
        metadata.workspace_members = workspace.members;
    }

    Ok(metadata)
}

/// Determines the target directory (respects CARGO_TARGET_DIR and .cargo/config.toml).
pub(crate) fn get_target_dir<P: AsRef<Path>>(project_root: P) -> PathBuf {
    // First check environment variable
    if let Ok(target_dir) = std::env::var("CARGO_TARGET_DIR") {
        return PathBuf::from(target_dir);
    }

    // For workspace packages, walk up to find workspace root
    let mut current = project_root.as_ref().to_path_buf();
    loop {
        let target_candidate = current.join("target");

        // Check if this is a workspace root by looking for Cargo.toml with [workspace]
        if let Ok(cargo_content) = std::fs::read_to_string(current.join("Cargo.toml")) {
            if cargo_content.contains("[workspace]") {
                // This is the workspace root
                return target_candidate;
            }
        }

        // Try parent directory
        match current.parent() {
            Some(parent) if parent != current => {
                current = parent.to_path_buf();
            }
            _ => {
                // Reached filesystem root, use project_root/target as fallback
                return project_root.as_ref().join("target");
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn test_find_cargo_toml_in_current_dir() {
        let temp_dir = tempdir().unwrap();
        let cargo_path = temp_dir.path().join("Cargo.toml");
        fs::write(&cargo_path, "").unwrap();

        let found = find_cargo_toml(temp_dir.path());
        assert_eq!(found, Some(cargo_path));
    }

    #[test]
    fn test_find_cargo_toml_walking_up() {
        let temp_dir = tempdir().unwrap();
        let cargo_path = temp_dir.path().join("Cargo.toml");
        fs::write(&cargo_path, "").unwrap();

        let sub_dir = temp_dir.path().join("src");
        fs::create_dir(&sub_dir).unwrap();

        let found = find_cargo_toml(&sub_dir);
        assert_eq!(found, Some(cargo_path));
    }

    #[test]
    fn test_find_cargo_toml_from_file() {
        let temp_dir = tempdir().unwrap();
        let cargo_path = temp_dir.path().join("Cargo.toml");
        fs::write(&cargo_path, "").unwrap();

        let src_dir = temp_dir.path().join("src");
        fs::create_dir(&src_dir).unwrap();
        let main_file = src_dir.join("main.rs");
        fs::write(&main_file, "").unwrap();

        let found = find_cargo_toml(&main_file);
        assert_eq!(found, Some(cargo_path));
    }

    #[test]
    fn test_parse_cargo_toml_basic() {
        let temp_dir = tempdir().unwrap();
        let cargo_path = temp_dir.path().join("Cargo.toml");
        let content = r#"
[package]
name = "test-project"
version = "1.0.0"
edition = "2021"

[[bin]]
name = "test-app"
path = "src/main.rs"
"#;
        fs::write(&cargo_path, content).unwrap();

        let metadata = parse_cargo_toml(&cargo_path).unwrap();
        assert_eq!(metadata.name, "test-project");
        assert_eq!(metadata.version, "1.0.0");
        assert_eq!(metadata.edition, "2021");
        assert_eq!(metadata.binaries.len(), 1);
        assert_eq!(metadata.binaries[0].name, "test-app");
    }

    #[test]
    fn test_parse_cargo_toml_with_examples() {
        let temp_dir = tempdir().unwrap();
        let cargo_path = temp_dir.path().join("Cargo.toml");
        let content = r#"
[package]
name = "example-project"
version = "0.5.0"
edition = "2021"

[[example]]
name = "demo"

[[example]]
name = "advanced"
"#;
        fs::write(&cargo_path, content).unwrap();

        let metadata = parse_cargo_toml(&cargo_path).unwrap();
        assert_eq!(metadata.name, "example-project");
        assert_eq!(metadata.examples.len(), 2);
        assert!(metadata.examples.contains(&"demo".to_string()));
        assert!(metadata.examples.contains(&"advanced".to_string()));
    }
}