cargo-sync-rdme 0.8.0

Cargo subcommand to synchronize README with crate documentation
use std::{
    collections::{HashMap, hash_map::Entry},
    sync::Arc,
};

use cargo_metadata::{Package, PackageId, PackageName};
use miette::Diagnostic;

use snafu::{ResultExt as _, Snafu};

use crate::{
    args::{Args, FeatureSelection},
    config::{ApplyLayer as _, Config},
    manifest::{ManifestError, ManifestLoader, ManifestLoaderError},
    source::SourceFilePath,
};

#[derive(Debug, Default)]
pub(crate) struct ConfigLoader {
    #[expect(clippy::option_option)]
    workspace_config: Option<Option<Arc<Config>>>,
    package_config: HashMap<PackageId, Option<Arc<Config>>>,
}

impl ConfigLoader {
    pub(crate) fn new() -> Self {
        Self::default()
    }

    pub(crate) fn load_workspace_config(
        &mut self,
        manifest_loader: &mut ManifestLoader<'_>,
    ) -> Result<Option<Arc<Config>>, ConfigLoaderError> {
        if let Some(workspace_config) = &self.workspace_config {
            return Ok(workspace_config.clone());
        }

        let manifest = manifest_loader.load_workspace_manifest()?;
        let config =
            manifest
                .workspace_config()
                .with_context(|_source| LoadWorkspaceManifestSnafu {
                    manifest: manifest.source_file(),
                })?;
        let config = config.map(Arc::new);
        self.workspace_config = Some(config.clone());
        Ok(config)
    }

    pub(crate) fn load_package_config(
        &mut self,
        manifest_loader: &mut ManifestLoader<'_>,
        package: &Package,
    ) -> Result<Option<Arc<Config>>, ConfigLoaderError> {
        let entry = match self.package_config.entry(package.id.clone()) {
            Entry::Occupied(entry) => return Ok(entry.get().clone()),
            Entry::Vacant(entry) => entry,
        };

        let manifest = manifest_loader.load_package_manifest(package)?;
        let config =
            manifest
                .package_config()
                .with_context(|_source| LoadPackageManifestFileSnafu {
                    package: package.name.clone(),
                    manifest: manifest.source_file(),
                })?;
        let config = config.map(Arc::new);
        entry.insert(config.clone());
        Ok(config)
    }
}

impl Config {
    pub(crate) fn from_args(args: &Args) -> Config {
        let mut config = Config::default();

        if let Some(toolchain) = &args.toolchain.toolchain {
            config.rustdoc.toolchain = Some(toolchain.clone());
        }

        let FeatureSelection {
            features,
            all_features,
            no_default_features,
        } = &args.feature;

        if !features.is_empty() {
            config.rustdoc.features = Some(features.clone());
        }
        if *all_features {
            config.rustdoc.all_features = Some(true);
        }
        if *no_default_features {
            config.rustdoc.no_default_features = Some(true);
        }

        config
    }

    pub(crate) fn load(
        manifest_loader: &mut ManifestLoader<'_>,
        config_loader: &mut ConfigLoader,
        args: &Args,
        package: &Package,
    ) -> Result<Self, ConfigLoaderError> {
        let mut config = Self::default();

        if let Some(workspace_config) = &config_loader.load_workspace_config(manifest_loader)? {
            config.apply_layer(workspace_config);
        }

        if let Some(package_config) =
            &config_loader.load_package_config(manifest_loader, package)?
        {
            config.apply_layer(package_config);
        }

        let args_config = Self::from_args(args);
        config.apply_layer(&args_config);

        Ok(config)
    }
}

#[derive(Debug, Snafu, Diagnostic)]
pub(crate) enum ConfigLoaderError {
    #[snafu(transparent)]
    #[diagnostic(transparent)]
    ManifestLoader {
        #[snafu(source)]
        #[diagnostic_source]
        source: Box<ManifestLoaderError>,
    },
    #[snafu(display("failed to load workspace manifest file: {path}", path = manifest.workspace_path))]
    LoadWorkspaceManifest {
        manifest: SourceFilePath,
        #[snafu(source)]
        #[diagnostic_source]
        source: Box<ManifestError>,
    },
    #[snafu(display("failed to load package manifest file for package `{package}`: {path}", path = manifest.workspace_path))]
    LoadPackageManifestFile {
        package: PackageName,
        manifest: SourceFilePath,
        #[snafu(source)]
        #[diagnostic_source]
        source: Box<ManifestError>,
    },
}

#[cfg(test)]
mod tests {
    use std::{slice, sync::LazyLock};

    use cargo_metadata::{
        Metadata, MetadataBuilder, PackageBuilder, WorkspaceDefaultMembers, camino::Utf8Path,
        semver::Version,
    };
    use indoc::indoc;
    use similar_asserts::assert_eq;

    use crate::{
        config::{
            Inheritable,
            badge::{
                BadgeStyle,
                item::{BadgeItem, BadgeItemKey},
            },
            rustdoc::Rustdoc,
            testing,
        },
        manifest::Manifest,
        source::SourceFile,
    };

    use super::*;

    static WORKSPACE_ROOT: LazyLock<&Utf8Path> =
        LazyLock::new(|| Utf8Path::new("/path/to/workspace"));

    fn package<N>(name: N) -> Package
    where
        N: Into<String>,
    {
        let name = name.into();
        let id = PackageId {
            repr: format!("{name}@0.1.0"),
        };
        let manifest_path = WORKSPACE_ROOT.join(&name).join("Cargo.toml");
        let name = PackageName::new(name);
        let version = Version::new(0, 1, 0);
        PackageBuilder::new(name, version, id, manifest_path)
            .build()
            .unwrap()
    }

    fn root_package<N>(name: N) -> Package
    where
        N: Into<String>,
    {
        let name = name.into();
        let id = PackageId {
            repr: format!("{name}@0.1.0"),
        };
        let manifest_path = WORKSPACE_ROOT.join("Cargo.toml");
        let name = PackageName::new(name);
        let version = Version::new(0, 1, 0);
        PackageBuilder::new(name, version, id, manifest_path)
            .build()
            .unwrap()
    }

    fn workspace(packages: &[Package]) -> Metadata {
        MetadataBuilder::default()
            .workspace_root(*WORKSPACE_ROOT)
            .workspace_members(
                packages
                    .iter()
                    .map(|package| package.id.clone())
                    .collect::<Vec<_>>(),
            )
            .workspace_default_members(WorkspaceDefaultMembers::default())
            .packages(packages)
            .resolve(None)
            .target_directory(WORKSPACE_ROOT.join("target"))
            .build_directory(None)
            .workspace_metadata(serde_json::Value::Null)
            .version(1_usize)
            .build()
            .unwrap()
    }

    fn manifest(workspace_path: &str, source: &str) -> Arc<Manifest> {
        let source = SourceFile::new_for_test(workspace_path, source);
        Arc::new(Manifest::new_for_test(&source).unwrap())
    }

    #[test]
    fn config_load_applies_workspace_and_package_layers_for_member_package() {
        let package = package("member");
        let workspace = workspace(slice::from_ref(&package));
        let mut manifest_loader = ManifestLoader::new(&workspace);
        let mut config_loader = ConfigLoader::new();

        manifest_loader.set_workspace_manifest(manifest(
            "Cargo.toml",
            indoc! {r#"
                [workspace.metadata.cargo-sync-rdme]
                extra-targets = ["./docs/workspace.md"]

                [workspace.metadata.cargo-sync-rdme.badge]
                style = "flat"
                badges = { license = { link = "https://workspace.example/license" } }

                [workspace.metadata.cargo-sync-rdme.rustdoc]
                html-root-url = "https://docs.example.com/workspace/"
                mappings = { "member::SharedType" = "https://reference.example.com/items/shared-type-from-workspace" }
            "#},
        ));
        manifest_loader.add_package_manifest(
            package.id.clone(),
            manifest(
                "member/Cargo.toml",
                indoc! {r#"
                    [package]
                    name = "member"
                    version = "0.1.0"

                    [package.metadata.cargo-sync-rdme]
                    extra-targets = ["./docs/package.md"]

                    [package.metadata.cargo-sync-rdme.badge]
                    style = "flat-square"
                    badges = { license = false, crates-io = true }

                    [package.metadata.cargo-sync-rdme.rustdoc]
                    mappings = {
                      "member::SharedType" = "https://reference.example.com/items/shared-type-from-package",
                      "member::PackageType" = "https://reference.example.com/items/package-type",
                    }
                "#},
            ),
        );

        let config = Config::load(
            &mut manifest_loader,
            &mut config_loader,
            &Args::default(),
            &package,
        )
        .unwrap();
        let Config {
            extra_targets,
            badge,
            rustdoc,
        } = config;

        assert_eq!(
            extra_targets.unwrap(),
            ["/path/to/workspace/member/docs/package.md"]
        );
        assert_eq!(badge.style, Some(BadgeStyle::FlatSquare));
        testing::assert_indexmap_eq(
            &badge.default.unwrap(),
            [
                (BadgeItemKey::License(None), Inheritable::Disabled),
                (
                    BadgeItemKey::CratesIo(None),
                    Inheritable::Value(BadgeItem::CratesIo),
                ),
            ],
        );
        assert_eq!(
            rustdoc,
            Rustdoc {
                html_root_url: Some("https://docs.example.com/workspace/".to_owned()),
                mappings: HashMap::from([
                    (
                        "member::SharedType".to_owned(),
                        "https://reference.example.com/items/shared-type-from-package".to_owned(),
                    ),
                    (
                        "member::PackageType".to_owned(),
                        "https://reference.example.com/items/package-type".to_owned(),
                    ),
                ]),
                ..Rustdoc::default()
            }
        );
    }

    #[test]
    fn config_load_applies_workspace_and_package_layers_for_root_package() {
        let package = root_package("root");
        let workspace = workspace(slice::from_ref(&package));
        let mut manifest_loader = ManifestLoader::new(&workspace);
        let mut config_loader = ConfigLoader::new();

        manifest_loader.add_package_manifest(
            package.id.clone(),
            manifest(
                "Cargo.toml",
                indoc! {r#"
                    [package]
                    name = "root"
                    version = "0.1.0"

                    [workspace.metadata.cargo-sync-rdme]
                    extra-targets = ["./docs/workspace.md"]

                    [package.metadata.cargo-sync-rdme]
                    extra-targets = ["./docs/package.md"]
                "#},
            ),
        );

        let config = Config::load(
            &mut manifest_loader,
            &mut config_loader,
            &Args::default(),
            &package,
        )
        .unwrap();

        assert_eq!(
            config.extra_targets.unwrap(),
            ["/path/to/workspace/docs/package.md"]
        );
    }
}