knope 0.22.3

A command line tool for automating common development tasks
use knope_config::{Template, Variable};
use knope_versioning::{Action, release_notes::Release, semver::Version};
use miette::Diagnostic;

use crate::{
    integrations::git::branch_name_from_issue,
    state,
    state::State,
    step::releases::{Package, package, semver},
};

/// Replace declared variables in the string and return the new string.
pub(crate) fn replace_variables(template: &Template, state: &mut State) -> Result<String, Error> {
    let mut package_cache = None;
    let template = template.replace_variables(|variable| match variable {
        Variable::Version => {
            let package = if let Some(package) = package_cache.take() {
                package
            } else {
                first_package(state)?
            };
            let version = package
                .versioning
                .latest_version()
                .ok_or(Error::NoVersion)?;
            package_cache = Some(package);
            Ok(version.to_string())
        }
        Variable::ChangelogEntry => {
            let package = if let Some(package) = package_cache.take() {
                package
            } else {
                first_package(state)?
            };
            if let Some(body) = state.pending_actions.iter().find_map(|action| {
                if let Action::CreateRelease(Release { notes, .. }) = action {
                    Some(notes)
                } else {
                    None
                }
            }) {
                package_cache = Some(package);
                Ok(body.clone())
            } else {
                let version = package
                    .versioning
                    .latest_version()
                    .ok_or(Error::NoVersion)?;
                let release = package
                    .versioning
                    .release_notes
                    .changelog
                    .as_ref()
                    .and_then(|changelog| changelog.get_release(&version, package.name()))
                    .ok_or_else(|| Error::NoChangelogEntry(version))?;
                package_cache = Some(package);
                Ok(release.notes)
            }
        }
        Variable::IssueBranch => match &state.issue {
            state::Issue::Initial => Err(Error::NoIssueSelected),
            state::Issue::Selected(issue) => Ok(branch_name_from_issue(issue)),
        },
    })?;
    if let Some(package) = package_cache {
        state.packages.push(package);
    }
    Ok(template)
}

fn first_package(state: &mut State) -> Result<Package, Error> {
    if state.packages.len() > 1 {
        Err(Error::TooManyPackages)
    } else if let Some(package) = state.packages.pop() {
        Ok(package)
    } else {
        Err(package::Error::NoDefinedPackages.into())
    }
}

#[derive(Debug, Diagnostic, thiserror::Error)]
pub(crate) enum Error {
    #[error("Too many packages defined")]
    #[diagnostic(
        code(variables::too_many_packages),
        help("The Version and Changelog variables can only be used with a single [package].")
    )]
    TooManyPackages,
    #[error(transparent)]
    #[diagnostic(transparent)]
    Package(#[from] package::Error),
    #[error("Could not determine version of package")]
    #[diagnostic(code(variables::no_version))]
    NoVersion,
    #[error("Could not find a changelog entry for version {0}")]
    #[diagnostic(
        code(variables::no_changelog_entry),
        url("https://knope.tech/reference/concepts/changelog/#versions")
    )]
    NoChangelogEntry(Version),
    #[error("No issue selected")]
    #[diagnostic(
        code(variables::no_issue_selected),
        help(
            "The IssueBranch command variable requires selecting an issue first with SelectGitHubIssue or SelectJiraIssue"
        )
    )]
    NoIssueSelected,
    #[error(transparent)]
    #[diagnostic(transparent)]
    SemVer(#[from] semver::Error),
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::indexing_slicing)]
mod test_replace_variables {
    use std::borrow::Cow;

    use indexmap::IndexMap;
    use knope_versioning::{
        Action, GoVersioning, VersionedFile, VersionedFileConfig,
        package::Name,
        release_notes::{Changelog, ReleaseNotes, Sections},
    };
    use pretty_assertions::assert_eq;
    use relative_path::RelativePathBuf;

    use super::*;
    use crate::step::issues::Issue;

    fn state() -> State {
        let changelog = Changelog::new(RelativePathBuf::default(), String::new());
        let versioned_file_path =
            VersionedFileConfig::new("Cargo.toml".into(), None, None).unwrap();
        let all_versioned_files = vec![
            VersionedFile::new(
                &versioned_file_path,
                "[package]\nversion = \"1.2.3\"\nname=\"blah\"".into(),
                &[""],
            )
            .unwrap(),
        ];

        let package = Package {
            versioning: knope_versioning::Package::new(
                Name::Default,
                &[""],
                vec![versioned_file_path],
                &all_versioned_files,
                ReleaseNotes {
                    sections: Sections::default(),
                    changelog: Some(changelog),
                    change_templates: Vec::new(),
                },
                None,
            )
            .unwrap(),
            ..Package::default()
        };

        State::new(
            None,
            None,
            None,
            vec![package],
            all_versioned_files,
            Vec::new(),
            false,
        )
    }

    #[test]
    fn replace_prepared_version() {
        let template = "blah $$ other blah".to_string();
        let mut variables = IndexMap::new();
        variables.insert(Cow::Borrowed("$$"), Variable::Version);
        let mut state = state();
        let version = Version::new(1, 2, 3, None);
        state.packages[0]
            .versioning
            .set_version(version.clone(), GoVersioning::Standard, Vec::new())
            .unwrap();

        let result =
            replace_variables(&Template::new(template, Some(variables)), &mut state).unwrap();

        assert_eq!(result, format!("blah {version} other blah"));
    }

    #[test]
    fn replace_issue_branch() {
        let template = "blah $$ other blah".to_string();
        let mut variables = IndexMap::new();
        variables.insert(Cow::Borrowed("$$"), Variable::IssueBranch);
        let issue = Issue {
            key: "13".to_string(),
            summary: "1234".to_string(),
        };
        let expected_branch_name = branch_name_from_issue(&issue);
        let mut state = State {
            jira_config: None,
            github: state::GitHub::New,
            github_config: None,
            gitea: state::Gitea::New,
            gitea_config: None,
            issue: state::Issue::Selected(issue),
            packages: Vec::new(),
            all_git_tags: Vec::new(),
            all_versioned_files: Vec::new(),
            pending_actions: Vec::new(),
            ignore_conventional_commits: false,
        };

        let result =
            replace_variables(&Template::new(template, Some(variables)), &mut state).unwrap();

        assert_eq!(result, format!("blah {expected_branch_name} other blah"));
    }

    #[test]
    fn replace_changelog_entry_prepared_release() {
        let template = "blah $changelog other blah".to_string();
        let mut state = state();
        let version = Version::new(1, 2, 3, None);
        let changelog_entry = "some content being put in the changelog";
        state.pending_actions = vec![Action::CreateRelease(Release {
            version: version.clone(),
            title: "title".to_string(),
            notes: changelog_entry.to_string(),
            package_name: Name::Default,
        })];

        let result = replace_variables(&Template::new(template, None), &mut state).unwrap();

        assert_eq!(result, format!("blah {changelog_entry} other blah"));
    }
}