cargo-sync-rdme 0.8.0

Cargo subcommand to synchronize README with crate documentation
use miette::{Diagnostic, SourceSpan};
use snafu::Snafu;

use crate::{
    config::Config,
    source::Spanned,
    sync::{
        PackageSyncContext,
        marker::{
            ResolvedReplaceSpecifier,
            parse::ReplaceSpecifier,
            scan::{ScanError, Scanner},
        },
    },
};

#[derive(Debug, Snafu, Diagnostic)]
pub(super) enum ResolveMarkerError {
    #[snafu(transparent)]
    #[diagnostic(transparent)]
    Scan {
        #[snafu(source)]
        #[diagnostic_source]
        source: ScanError,
    },
    #[snafu(display("unknown marker kind: {kind}"))]
    UnknownMarkerKind {
        kind: String,
        #[label]
        span: SourceSpan,
    },
    #[snafu(display("marker kind `{kind}` cannot have group suffix `:{group}`"))]
    UnexpectedGroupForSpecifier {
        kind: String,
        group: String,
        #[label]
        span: SourceSpan,
    },
    #[snafu(display(
        "default badge group is not configured in the package manifest: package.metadata.cargo-sync-rdme.badge.badges"
    ))]
    NoDefaultBadgeConfigured {
        #[label]
        span: SourceSpan,
    },
    #[snafu(display(
        "badge group not found in the package manifest: package.metadata.cargo-sync-rdme.badge.badges-{group}"
    ))]
    NoSuchBadgeGroup {
        group: String,
        #[label]
        span: SourceSpan,
    },
}

#[derive(Debug)]
pub(super) struct Resolver<'cx, 'markdown> {
    cx: &'cx PackageSyncContext<'cx>,
    scanner: Scanner<'markdown>,
}

impl<'cx, 'markdown> Resolver<'cx, 'markdown> {
    pub(super) fn new(cx: &'cx PackageSyncContext<'cx>, markdown: &'markdown str) -> Self {
        Self {
            cx,
            scanner: Scanner::new(markdown),
        }
    }

    pub(super) fn try_next(
        &mut self,
    ) -> Result<Option<Spanned<ResolvedReplaceSpecifier<'cx>>>, ResolveMarkerError> {
        let Some(chunk) = self.scanner.try_next()? else {
            return Ok(None);
        };
        let resolved = resolve_specifier(chunk.value.specifier, &self.cx.config)?;
        Ok(Some(Spanned::new(resolved, chunk.span)))
    }
}

pub(super) fn resolve_specifier<'a>(
    specifier: Spanned<ReplaceSpecifier<'_>>,
    config: &'a Config,
) -> Result<ResolvedReplaceSpecifier<'a>, ResolveMarkerError> {
    let kind = specifier.value.kind;
    let group = specifier.value.group;
    match (kind.value, group) {
        ("title", None) => return Ok(ResolvedReplaceSpecifier::Title),
        ("rustdoc", None) => return Ok(ResolvedReplaceSpecifier::Rustdoc),
        ("title" | "rustdoc", Some(group)) => {
            return Err(ResolveMarkerError::UnexpectedGroupForSpecifier {
                kind: kind.value.to_string(),
                group: group.value.to_string(),
                span: specifier.source_span(),
            });
        }
        ("badge", _) => {}
        _ => {
            return Err(ResolveMarkerError::UnknownMarkerKind {
                kind: kind.value.to_string(),
                span: kind.source_span(),
            });
        }
    }

    let badge = &config.badge;
    if let Some(group) = group {
        let (group, badges) = badge.groups.get_key_value(group.value).ok_or_else(|| {
            NoSuchBadgeGroupSnafu {
                group: group.value,
                span: group.source_span(),
            }
            .build()
        })?;
        Ok(ResolvedReplaceSpecifier::Badge {
            group: Some(group),
            badges,
        })
    } else {
        let badges = badge.default.as_ref().ok_or_else(|| {
            NoDefaultBadgeConfiguredSnafu {
                span: specifier.source_span(),
            }
            .build()
        })?;
        Ok(ResolvedReplaceSpecifier::Badge {
            group: None,
            badges,
        })
    }
}

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

    use similar_asserts::assert_eq;

    use crate::{
        config::badge::BadgeMap, manifest::Manifest, source::SourceFile, sync::marker::parse,
    };

    use super::*;

    static CONFIG_SOURCE: &str = indoc::indoc! {r#"
        [package]
        name = "foo"
        version = "0.1.0"

        [package.metadata.cargo-sync-rdme.badge.badges]
        [package.metadata.cargo-sync-rdme.badge.badges-foo]
    "#};
    static CONFIG: LazyLock<Config> = LazyLock::new(|| {
        let source_file = SourceFile::new_for_test("Cargo.toml", CONFIG_SOURCE);
        let manifest = Manifest::new_for_test(&source_file).unwrap();
        manifest.package_config().unwrap().unwrap()
    });

    impl<'a> ResolvedReplaceSpecifier<'a> {
        #[track_caller]
        pub(crate) fn into_title(self) {
            let Self::Title = self else {
                panic!("unexpected replace specifier: {self:?}");
            };
        }

        #[track_caller]
        pub(crate) fn into_rustdoc(self) {
            let Self::Rustdoc = self else {
                panic!("unexpected replace specifier: {self:?}");
            };
        }

        #[track_caller]
        pub(crate) fn into_badge(self) -> (Option<&'a str>, &'a BadgeMap) {
            let Self::Badge { group, badges } = self else {
                panic!("unexpected replace specifier: {self:?}");
            };
            (group, badges)
        }
    }

    impl ResolveMarkerError {
        #[track_caller]
        pub(crate) fn into_unknown_marker_kind(self) -> (String, SourceSpan) {
            let Self::UnknownMarkerKind { kind, span } = self else {
                panic!("unexpected error: {self:?}");
            };
            (kind, span)
        }
        #[track_caller]
        pub(crate) fn into_unexpected_group_for_specifier(self) -> (String, String, SourceSpan) {
            let Self::UnexpectedGroupForSpecifier { kind, group, span } = self else {
                panic!("unexpected error: {self:?}");
            };
            (kind, group, span)
        }

        #[track_caller]
        pub(crate) fn into_no_default_badge_configured(self) -> SourceSpan {
            let Self::NoDefaultBadgeConfigured { span } = self else {
                panic!("unexpected error: {self:?}");
            };
            span
        }

        #[track_caller]
        pub(crate) fn into_no_such_badge_group(self) -> (String, SourceSpan) {
            let Self::NoSuchBadgeGroup { group, span } = self else {
                panic!("unexpected error: {self:?}");
            };
            (group, span)
        }
    }

    fn resolve<'a>(
        source: Spanned<&str>,
        config: &'a Config,
    ) -> Result<ResolvedReplaceSpecifier<'a>, ResolveMarkerError> {
        let (specifier, _rest) = parse::parse_specifier(source).unwrap().unwrap();
        resolve_specifier(specifier, config)
    }

    #[test]
    fn resolve_marker_resolves_valid_markers() {
        let source = Spanned::from_str("title");
        let resolved = resolve(source, &CONFIG).unwrap();
        resolved.into_title();

        let source = Spanned::from_str("rustdoc");
        let resolved = resolve(source, &CONFIG).unwrap();
        resolved.into_rustdoc();

        let source = Spanned::from_str("badge");
        let resolved = resolve(source, &CONFIG).unwrap();
        let (group, _badges) = resolved.into_badge();
        assert!(group.is_none());

        let source = Spanned::from_str("badge:foo");
        let resolved = resolve(source, &CONFIG).unwrap();
        let (group, _badges) = resolved.into_badge();
        assert_eq!(group.unwrap(), "foo");
    }

    #[test]
    fn resolve_marker_rejects_invalid_markers() {
        let source = Spanned::from_str("unknown");
        let (kind, span) = resolve(source, &CONFIG)
            .unwrap_err()
            .into_unknown_marker_kind();
        assert_eq!(kind, "unknown");
        source.assert_source_span(span, "unknown");

        let source = Spanned::from_str("title:foo");
        let (kind, group, span) = resolve(source, &CONFIG)
            .unwrap_err()
            .into_unexpected_group_for_specifier();
        assert_eq!(kind, "title");
        assert_eq!(group, "foo");
        source.assert_source_span(span, "title:foo");

        let source = Spanned::from_str("rustdoc:foo");
        let (kind, group, span) = resolve(source, &CONFIG)
            .unwrap_err()
            .into_unexpected_group_for_specifier();
        assert_eq!(kind, "rustdoc");
        assert_eq!(group, "foo");
        source.assert_source_span(span, "rustdoc:foo");

        let source = Spanned::from_str("badge:bar");
        let (group, span) = resolve(source, &CONFIG)
            .unwrap_err()
            .into_no_such_badge_group();
        assert_eq!(group, "bar");
        source.assert_source_span(span, "bar");

        let source = Spanned::from_str("badge");
        let span = resolve(source, &Config::default())
            .unwrap_err()
            .into_no_default_badge_configured();
        source.assert_source_span(span, "badge");
    }
}