axbuild 0.4.20

An OS build lib toolkit used by arceos
Documentation
use std::{
    collections::BTreeSet,
    fs,
    path::{Path, PathBuf},
    process::Command,
};

use anyhow::{Context, bail};
use toml::Value;

use super::refs::git_safe_directory_args;

pub(super) const ROOT_MANIFEST: &str = "Cargo.toml";
const WORKSPACE_TABLE: &str = "workspace";
const WORKSPACE_DEPENDENCIES_TABLE: &str = "dependencies";
const WORKSPACE_MEMBERS: &str = "members";
const WORKSPACE_PACKAGE_TABLE: &str = "package";
const WORKSPACE_METADATA_TABLE: &str = "metadata";
const PROFILE_TABLE: &str = "profile";

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum RootManifestChange {
    Hard,
    LocalWorkspace(LocalRootManifestChange),
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(super) struct LocalRootManifestChange {
    pub(super) dependencies: BTreeSet<String>,
    pub(super) members: BTreeSet<PathBuf>,
}

pub(super) fn root_manifest_change_since(
    workspace_root: &Path,
    since: &str,
) -> anyhow::Result<RootManifestChange> {
    let old_manifest = git_show_file(workspace_root, since, ROOT_MANIFEST).with_context(|| {
        format!("failed to read `{ROOT_MANIFEST}` from `{since}` for incremental clippy")
    })?;
    let new_manifest =
        fs::read_to_string(workspace_root.join(ROOT_MANIFEST)).with_context(|| {
            format!(
                "failed to read current `{}`",
                workspace_root.join(ROOT_MANIFEST).display()
            )
        })?;

    classify_root_manifest_change(&old_manifest, &new_manifest).with_context(|| {
        format!("failed to classify `{ROOT_MANIFEST}` changes for incremental clippy")
    })
}

fn git_show_file(workspace_root: &Path, rev: &str, path: &str) -> anyhow::Result<String> {
    let spec = format!("{rev}:{path}");
    let output = Command::new("git")
        .args(git_safe_directory_args(workspace_root))
        .arg("-C")
        .arg(workspace_root)
        .args(["show", spec.as_str()])
        .output()
        .with_context(|| format!("failed to run git show `{spec}`"))?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        bail!(
            "git show `{spec}` exited with status {}{}",
            output.status,
            if stderr.is_empty() {
                String::new()
            } else {
                format!(": {stderr}")
            }
        );
    }

    String::from_utf8(output.stdout).context("git show output was not valid UTF-8")
}

pub(super) fn classify_root_manifest_change(
    old_manifest: &str,
    new_manifest: &str,
) -> anyhow::Result<RootManifestChange> {
    let old: Value = toml::from_str(old_manifest).context("failed to parse old root Cargo.toml")?;
    let new: Value = toml::from_str(new_manifest).context("failed to parse new root Cargo.toml")?;

    let Some(changed_members) = changed_literal_workspace_members(&old, &new) else {
        return Ok(RootManifestChange::Hard);
    };

    if dependency_resolution_surface(old.clone()) != dependency_resolution_surface(new.clone()) {
        return Ok(RootManifestChange::Hard);
    }

    let old_dependencies = workspace_dependencies(&old);
    let new_dependencies = workspace_dependencies(&new);
    let mut changed = BTreeSet::new();
    for dependency_key in old_dependencies
        .keys()
        .chain(new_dependencies.keys())
        .collect::<BTreeSet<_>>()
    {
        let old_dependency = old_dependencies.get(dependency_key.as_str());
        let new_dependency = new_dependencies.get(dependency_key.as_str());
        if old_dependency == new_dependency {
            continue;
        }
        let old_package = old_dependency.and_then(|dependency| {
            local_workspace_dependency_package_name(dependency_key, dependency)
        });
        let new_package = new_dependency.and_then(|dependency| {
            local_workspace_dependency_package_name(dependency_key, dependency)
        });
        if old_package.is_none() && new_package.is_none() {
            return Ok(RootManifestChange::Hard);
        }
        changed.extend(old_package);
        changed.extend(new_package);
    }

    Ok(RootManifestChange::LocalWorkspace(
        LocalRootManifestChange {
            dependencies: changed,
            members: changed_members,
        },
    ))
}

fn dependency_resolution_surface(mut manifest: Value) -> Value {
    if let Some(workspace) = manifest
        .get_mut(WORKSPACE_TABLE)
        .and_then(Value::as_table_mut)
    {
        workspace.remove(WORKSPACE_DEPENDENCIES_TABLE);
        workspace.remove(WORKSPACE_MEMBERS);
        workspace.remove(WORKSPACE_PACKAGE_TABLE);
        workspace.remove(WORKSPACE_METADATA_TABLE);
    }
    manifest
        .as_table_mut()
        .map(|table| table.remove(PROFILE_TABLE));
    manifest
}

fn changed_literal_workspace_members(old: &Value, new: &Value) -> Option<BTreeSet<PathBuf>> {
    let old_members = workspace_members(old);
    let new_members = workspace_members(new);
    if old_members == new_members {
        return Some(BTreeSet::new());
    }

    let old_members = workspace_member_strings(old)?;
    let new_members = workspace_member_strings(new)?;

    old_members
        .symmetric_difference(&new_members)
        .map(|member| normalize_literal_workspace_member(member))
        .collect()
}

fn workspace_members(manifest: &Value) -> Option<&Value> {
    manifest
        .get(WORKSPACE_TABLE)
        .and_then(|workspace| workspace.get(WORKSPACE_MEMBERS))
}

fn workspace_member_strings(manifest: &Value) -> Option<BTreeSet<String>> {
    let Some(members) = workspace_members(manifest) else {
        return Some(BTreeSet::new());
    };
    let members = members.as_array()?;

    let mut member_strings = BTreeSet::new();
    for member in members {
        let member = member.as_str()?;
        member_strings.insert(member.to_string());
    }
    Some(member_strings)
}

fn normalize_literal_workspace_member(member: &str) -> Option<PathBuf> {
    if member
        .bytes()
        .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']'))
    {
        return None;
    }

    let path = Path::new(member);
    if path.is_absolute() {
        return None;
    }

    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            std::path::Component::Normal(part) => normalized.push(part),
            std::path::Component::CurDir => {}
            _ => return None,
        }
    }
    Some(normalized)
}

fn workspace_dependencies(manifest: &Value) -> toml::Table {
    manifest
        .get(WORKSPACE_TABLE)
        .and_then(|workspace| workspace.get(WORKSPACE_DEPENDENCIES_TABLE))
        .and_then(Value::as_table)
        .cloned()
        .unwrap_or_default()
}

fn is_local_workspace_dependency(dependency: &Value) -> bool {
    dependency
        .as_table()
        .and_then(|table| table.get("path"))
        .is_some_and(Value::is_str)
}

fn local_workspace_dependency_package_name(
    dependency_key: &str,
    dependency: &Value,
) -> Option<String> {
    if !is_local_workspace_dependency(dependency) {
        return None;
    }

    Some(
        dependency
            .as_table()
            .and_then(|table| table.get("package"))
            .and_then(Value::as_str)
            .unwrap_or(dependency_key)
            .to_string(),
    )
}