cargo-allow 0.1.3

Source-tree exception ledger and policy scanner for Rust repositories.
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};

const PUBLISHED_RELEASE_VERSION: &str = "0.1.2";
const PREVIOUS_PUBLISHED_VERSION: &str = "0.1.1";
const PUBLISHED_RELEASE_DOC: &str = "docs/release/0.1.2.md";
const CANDIDATE_RELEASE_VERSION: &str = "0.1.3";
const CANDIDATE_RELEASE_DOC: &str = "docs/release/0.1.3.md";

#[test]
fn release_0_1_2_publish_order_matches_internal_dependency_graph() {
    let root = workspace_root();
    let release_doc = fs::read_to_string(root.join(PUBLISHED_RELEASE_DOC)).unwrap_or_else(|err| {
        std::panic::panic_any(format!("read {PUBLISHED_RELEASE_DOC}: {err}"))
    });
    let publish_order = parse_publish_order(&release_doc);
    let package_manifests = workspace_package_manifests(&root);
    let package_names = package_manifests.keys().cloned().collect::<BTreeSet<_>>();

    assert_eq!(
        publish_order.iter().cloned().collect::<BTreeSet<_>>(),
        package_names,
        "{PUBLISHED_RELEASE_VERSION} publish order should include every workspace package exactly once"
    );
    assert_eq!(
        publish_order.len(),
        package_names.len(),
        "{PUBLISHED_RELEASE_VERSION} publish order should not contain duplicate packages"
    );

    let order_index = publish_order
        .iter()
        .enumerate()
        .map(|(index, package)| (package.as_str(), index))
        .collect::<BTreeMap<_, _>>();

    for (package, manifest) in package_manifests {
        let package_index = release_order_index(&order_index, package.as_str());
        for dependency in internal_workspace_dependencies(&manifest, &package_names) {
            let dependency_index = release_order_index(&order_index, dependency.as_str());
            assert!(
                dependency_index < package_index,
                "{package} depends on {dependency}, so {dependency} must be published first"
            );
        }
    }
}

#[test]
fn release_0_1_2_record_keeps_completed_publication_evidence() {
    let root = workspace_root();
    let release_doc = fs::read_to_string(root.join(PUBLISHED_RELEASE_DOC)).unwrap_or_else(|err| {
        std::panic::panic_any(format!("read {PUBLISHED_RELEASE_DOC}: {err}"))
    });

    assert!(
        release_doc.contains("# 0.1.2 Release Record"),
        "release record should name the completed patch release"
    );
    assert!(
        release_doc.contains("to `0.1.2`"),
        "release record should document the completed version bump"
    );
    assert!(
        release_doc.contains("--version 0.1.2"),
        "release record should smoke-test the published {PUBLISHED_RELEASE_VERSION} binary"
    );
}

#[test]
fn release_0_1_3_candidate_versions_are_staged_without_publication_claims() {
    let root = workspace_root();
    let release_doc = read_workspace_file(&root, CANDIDATE_RELEASE_DOC);
    let workspace_manifest = read_workspace_file(&root, "Cargo.toml");
    let lockfile = read_workspace_file(&root, "Cargo.lock");
    let package_manifests = workspace_package_manifests(&root);
    let workspace_version = workspace_package_version(&workspace_manifest);

    assert_eq!(
        workspace_version, CANDIDATE_RELEASE_VERSION,
        "{CANDIDATE_RELEASE_VERSION} release candidate should stage the workspace package version"
    );
    assert!(
        release_doc.contains("# 0.1.3 Release Candidate"),
        "0.1.3 note should name itself as a candidate, not a completed release record"
    );
    assert!(
        release_doc.contains("not a publication record"),
        "0.1.3 candidate note should say it is not a publication record"
    );
    assert!(
        release_doc.contains("has not been") && release_doc.contains("tagged or published"),
        "0.1.3 candidate note should deny completed release actions"
    );
    assert!(
        release_doc.contains("workspace package version")
            && release_doc.contains("internal dependency requirements")
            && release_doc.contains("staged at `0.1.3`"),
        "0.1.3 candidate note should document the staged version bump"
    );

    for (package, manifest) in &package_manifests {
        assert!(
            manifest.contains("version.workspace = true"),
            "{package} should inherit the workspace release version"
        );
    }

    let package_names = package_manifests.keys().cloned().collect::<BTreeSet<_>>();
    let workspace_dependency_versions =
        workspace_internal_dependency_versions(&workspace_manifest, &package_names);
    let mut expected_workspace_dependency_names = package_names.clone();
    expected_workspace_dependency_names.remove("cargo-allow");
    assert_eq!(
        workspace_dependency_versions
            .keys()
            .cloned()
            .collect::<BTreeSet<_>>(),
        expected_workspace_dependency_names,
        "workspace dependencies should version every internal library crate"
    );
    for (dependency, version) in workspace_dependency_versions {
        assert_eq!(
            version, workspace_version,
            "{dependency} workspace dependency should require the release-candidate version"
        );
    }

    let lock_versions = lockfile_package_versions(&lockfile, &package_names);
    assert_eq!(
        lock_versions.keys().cloned().collect::<BTreeSet<_>>(),
        package_names,
        "Cargo.lock should contain every workspace package"
    );
    for (package, version) in lock_versions {
        assert_eq!(
            version, workspace_version,
            "{package} lockfile entry should carry the release-candidate version"
        );
    }

    for relative_path in release_install_surfaces() {
        let content = read_workspace_file(&root, relative_path);
        assert!(
            content.contains(&format!(
                "cargo install cargo-allow --version {PUBLISHED_RELEASE_VERSION} --locked"
            )),
            "{relative_path} should keep the published {PUBLISHED_RELEASE_VERSION} install pin until {CANDIDATE_RELEASE_VERSION} is published"
        );
        assert!(
            !content.contains(&format!(
                "cargo install cargo-allow --version {CANDIDATE_RELEASE_VERSION} --locked"
            )),
            "{relative_path} should not advertise unpublished {CANDIDATE_RELEASE_VERSION}"
        );
    }
}

#[test]
fn release_0_1_2_install_examples_use_published_release() {
    let root = workspace_root();
    let release_doc = read_workspace_file(&root, PUBLISHED_RELEASE_DOC);

    assert!(
        release_doc.contains("Public install examples now pin the published `0.1.2` release"),
        "release record should note that public install examples moved to the published release"
    );

    for relative_path in release_install_surfaces() {
        let content = read_workspace_file(&root, relative_path);
        assert!(
            content.contains(&format!(
                "cargo install cargo-allow --version {PUBLISHED_RELEASE_VERSION} --locked"
            )),
            "{relative_path} should install the published cargo-allow {PUBLISHED_RELEASE_VERSION} release"
        );
        assert!(
            !content.contains(&format!(
                "cargo install cargo-allow --version {PREVIOUS_PUBLISHED_VERSION} --locked"
            )),
            "{relative_path} should not keep the previous {PREVIOUS_PUBLISHED_VERSION} install pin after publication"
        );
    }
}

fn workspace_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .unwrap_or_else(|| std::panic::panic_any("cargo-allow manifest should be under crates/"))
        .to_path_buf()
}

fn release_install_surfaces() -> &'static [&'static str] {
    &[
        "README.md",
        "docs/ci.md",
        "examples/github-actions/cargo-allow-check.yml",
        "examples/github-actions/cargo-allow-diff.yml",
    ]
}

fn read_workspace_file(root: &Path, relative_path: &str) -> String {
    fs::read_to_string(root.join(relative_path))
        .unwrap_or_else(|err| std::panic::panic_any(format!("read {relative_path}: {err}")))
}

fn workspace_package_manifests(root: &Path) -> BTreeMap<String, String> {
    let crates_dir = root.join("crates");
    let entries = fs::read_dir(&crates_dir)
        .unwrap_or_else(|err| std::panic::panic_any(format!("read crates dir: {err}")));
    let mut manifests = BTreeMap::new();
    for entry in entries {
        let entry =
            entry.unwrap_or_else(|err| std::panic::panic_any(format!("read crate dir: {err}")));
        if !entry
            .file_type()
            .unwrap_or_else(|err| std::panic::panic_any(format!("read crate file type: {err}")))
            .is_dir()
        {
            continue;
        }
        let manifest_path = entry.path().join("Cargo.toml");
        let manifest = fs::read_to_string(&manifest_path).unwrap_or_else(|err| {
            std::panic::panic_any(format!("read {}: {err}", manifest_path.display()))
        });
        let Some(package) = manifest_value(&manifest, "name") else {
            std::panic::panic_any(format!(
                "{} should declare package name",
                manifest_path.display()
            ));
        };
        manifests.insert(package, manifest);
    }
    manifests
}

fn parse_publish_order(release_doc: &str) -> Vec<String> {
    let Some(section) = release_doc.split("## Publish Order").nth(1) else {
        std::panic::panic_any(format!(
            "{PUBLISHED_RELEASE_VERSION} release doc should contain Publish Order section"
        ));
    };
    let Some(block) = section.split("```text").nth(1) else {
        std::panic::panic_any("Publish Order section should contain text code block");
    };
    let Some(block) = block.split("```").next() else {
        std::panic::panic_any("Publish Order code block should be closed");
    };

    block
        .lines()
        .filter_map(|line| line.split_once('.').map(|(_, package)| package.trim()))
        .filter(|package| !package.is_empty())
        .map(ToOwned::to_owned)
        .collect()
}

fn internal_workspace_dependencies(
    manifest: &str,
    package_names: &BTreeSet<String>,
) -> BTreeSet<String> {
    manifest
        .lines()
        .filter_map(|line| {
            let (name, value) = line.split_once('=')?;
            let name = name.trim();
            let dependency = name
                .strip_suffix(".workspace")
                .filter(|_| value.trim() == "true")
                .unwrap_or(name);
            if package_names.contains(dependency)
                && (value.contains("workspace = true") || name.ends_with(".workspace"))
            {
                Some(dependency.to_string())
            } else {
                None
            }
        })
        .collect()
}

fn workspace_package_version(workspace_manifest: &str) -> String {
    let mut in_workspace_package = false;
    for line in workspace_manifest.lines() {
        let line = line.trim();
        if line.starts_with('[') {
            in_workspace_package = line == "[workspace.package]";
            continue;
        }
        if in_workspace_package {
            if let Some(version) = manifest_value(line, "version") {
                return version;
            }
        }
    }
    std::panic::panic_any("workspace manifest should declare workspace.package.version");
}

fn workspace_internal_dependency_versions(
    workspace_manifest: &str,
    package_names: &BTreeSet<String>,
) -> BTreeMap<String, String> {
    workspace_manifest
        .lines()
        .filter_map(|line| {
            let (name, value) = line.split_once('=')?;
            let name = name.trim();
            if package_names.contains(name) {
                Some((name.to_string(), inline_table_version(value)))
            } else {
                None
            }
        })
        .collect()
}

fn inline_table_version(value: &str) -> String {
    value
        .split(',')
        .find_map(|field| manifest_value(field.trim().trim_end_matches('}').trim(), "version"))
        .unwrap_or_else(|| std::panic::panic_any(format!("dependency {value} should set version")))
}

fn lockfile_package_versions(
    lockfile: &str,
    package_names: &BTreeSet<String>,
) -> BTreeMap<String, String> {
    let mut versions = BTreeMap::new();
    let mut current_name = None::<String>;
    let mut current_version = None::<String>;

    for line in lockfile.lines() {
        let line = line.trim();
        if line == "[[package]]" {
            record_lockfile_package(
                &mut versions,
                package_names,
                current_name.take(),
                current_version.take(),
            );
            continue;
        }
        if current_name.is_none() {
            current_name = manifest_value(line, "name");
        }
        if current_version.is_none() {
            current_version = manifest_value(line, "version");
        }
    }
    record_lockfile_package(&mut versions, package_names, current_name, current_version);
    versions
}

fn record_lockfile_package(
    versions: &mut BTreeMap<String, String>,
    package_names: &BTreeSet<String>,
    name: Option<String>,
    version: Option<String>,
) {
    let Some(name) = name else {
        return;
    };
    if !package_names.contains(&name) {
        return;
    }
    let Some(version) = version else {
        std::panic::panic_any(format!("lockfile package {name} should include version"));
    };
    versions.insert(name, version);
}

fn release_order_index(order_index: &BTreeMap<&str, usize>, package: &str) -> usize {
    if let Some(index) = order_index.get(package) {
        *index
    } else {
        std::panic::panic_any(format!("release publish order should include {package}"));
    }
}

fn manifest_value(manifest: &str, key: &str) -> Option<String> {
    let prefix = format!("{key} = ");
    manifest.lines().find_map(|line| {
        line.trim()
            .strip_prefix(&prefix)?
            .trim()
            .strip_prefix('"')?
            .strip_suffix('"')
            .map(ToOwned::to_owned)
    })
}