refactor: move commands and misc out of main binary package

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2023-08-01 15:34:24 +02:00
parent 8b83b9c14d
commit c7793f7422
24 changed files with 580 additions and 460 deletions

View File

@@ -5,6 +5,8 @@ edition = "2021"
[dependencies]
cuddle-please-frontend.workspace = true
cuddle-please-commands.workspace = true
cuddle-please-misc.workspace = true
anyhow.workspace = true
tracing.workspace = true

View File

@@ -1,531 +0,0 @@
use anyhow::Context;
use chrono::{DateTime, NaiveDate, Utc};
use git_cliff_core::{
changelog::Changelog,
commit::Commit,
config::{ChangelogConfig, CommitParser, Config, GitConfig},
release::Release,
};
use regex::Regex;
pub struct ChangeLogBuilder {
commits: Vec<String>,
version: String,
config: Option<Config>,
release_date: Option<NaiveDate>,
release_link: Option<String>,
}
impl ChangeLogBuilder {
pub fn new<C>(commits: C, version: impl Into<String>) -> Self
where
C: IntoIterator,
C::Item: AsRef<str>,
{
Self {
commits: commits
.into_iter()
.map(|s| s.as_ref().to_string())
.collect(),
version: version.into(),
config: None,
release_date: None,
release_link: None,
}
}
pub fn with_release_date(self, release_date: NaiveDate) -> Self {
Self {
release_date: Some(release_date),
..self
}
}
pub fn with_release_link(self, release_link: impl Into<String>) -> Self {
Self {
release_link: Some(release_link.into()),
..self
}
}
pub fn with_config(self, config: Config) -> Self {
Self {
config: Some(config),
..self
}
}
pub fn build<'a>(self) -> ChangeLog<'a> {
let git_config = self
.config
.clone()
.map(|c| c.git)
.unwrap_or_else(default_git_config);
let timestamp = self.release_timestamp();
let commits = self
.commits
.clone()
.into_iter()
.map(|c| Commit::new("id".into(), c))
.filter_map(|c| c.process(&git_config).ok())
.collect();
ChangeLog {
release: Release {
version: Some(self.version),
commits,
commit_id: None,
timestamp,
previous: None,
},
config: self.config,
release_link: self.release_link,
}
}
fn release_timestamp(&self) -> i64 {
self.release_date
.and_then(|date| date.and_hms_opt(0, 0, 0))
.map(|d| DateTime::<Utc>::from_utc(d, Utc))
.unwrap_or_else(Utc::now)
.timestamp()
}
}
pub struct ChangeLog<'a> {
release: Release<'a>,
config: Option<Config>,
release_link: Option<String>,
}
impl ChangeLog<'_> {
pub fn generate(&self) -> anyhow::Result<String> {
let config = self.config.clone().unwrap_or_else(|| self.default_config());
let changelog = Changelog::new(vec![self.release.clone()], &config)?;
let mut buffer = Vec::new();
changelog
.generate(&mut buffer)
.context("failed to generate changelog")?;
String::from_utf8(buffer)
.context("cannot convert bytes to string (contains non utf-8 char indices)")
}
pub fn prepend(self, old_changelog: impl Into<String>) -> anyhow::Result<String> {
let old_changelog = old_changelog.into();
if let Ok(Some(last_version)) = changelog_parser::last_version_from_str(&old_changelog) {
let next_version = self
.release
.version
.as_ref()
.context("current release contains no version")?;
if next_version == &last_version {
return Ok(old_changelog);
}
}
let old_header = changelog_parser::parse_header(&old_changelog);
let config = self
.config
.clone()
.unwrap_or_else(|| self.default_config_with_header(old_header));
let changelog = Changelog::new(vec![self.release], &config)?;
let mut out = Vec::new();
changelog.prepend(old_changelog, &mut out)?;
String::from_utf8(out)
.context("cannot convert bytes to string (contains non utf-8 char indices)")
}
fn default_config(&self) -> Config {
let config = Config {
changelog: default_changelog_config(None, self.release_link.as_deref()),
git: default_git_config(),
};
config
}
fn default_config_with_header(&self, header: Option<String>) -> Config {
let config = Config {
changelog: default_changelog_config(header, self.release_link.as_deref()),
git: default_git_config(),
};
config
}
}
fn default_git_config() -> GitConfig {
GitConfig {
conventional_commits: Some(true),
filter_unconventional: Some(false),
filter_commits: Some(true),
commit_parsers: Some(default_commit_parsers()),
..Default::default()
}
}
fn default_commit_parsers() -> Vec<CommitParser> {
fn create_commit_parser(message: &str, group: &str) -> CommitParser {
CommitParser {
message: Regex::new(&format!("^{message}")).ok(),
body: None,
group: Some(group.into()),
default_scope: None,
scope: None,
skip: None,
}
}
vec![
create_commit_parser("feat", "added"),
create_commit_parser("changed", "changed"),
create_commit_parser("deprecated", "deprecated"),
create_commit_parser("removed", "removed"),
create_commit_parser("fix", "fixed"),
create_commit_parser("security", "security"),
CommitParser {
message: Regex::new(".*").ok(),
group: Some(String::from("other")),
body: None,
default_scope: None,
skip: None,
scope: None,
},
]
}
const CHANGELOG_HEADER: &str = r#"# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
"#;
fn default_changelog_config(header: Option<String>, release_link: Option<&str>) -> ChangelogConfig {
ChangelogConfig {
header: Some(header.unwrap_or(String::from(CHANGELOG_HEADER))),
body: Some(default_changelog_body_config(release_link)),
footer: None,
trim: Some(true),
}
}
fn default_changelog_body_config(release_link: Option<&str>) -> String {
const PRE: &str = r#"
## [{{ version | trim_start_matches(pat="v") }}]"#;
const POST: &str = r#" - {{ timestamp | date(format="%Y-%m-%d") }}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
{%- if commit.scope -%}
- *({{commit.scope}})* {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}{%- if commit.links %} ({% for link in commit.links %}[{{link.text}}]({{link.href}}) {% endfor -%}){% endif %}
{% else -%}
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }}
{% endif -%}
{% endfor -%}
{% endfor %}"#;
match release_link {
Some(link) => format!("{}{}{}", PRE, link, POST),
None => format!("{}{}", PRE, POST),
}
}
pub mod changelog_parser {
use anyhow::Context;
use regex::Regex;
/// Parse the header from a changelog.
/// The changelog header is a string at the begin of the changelog that:
/// - Starts with `# Changelog`, `# CHANGELOG`, or `# changelog`
/// - ends with `## Unreleased`, `## [Unreleased]` or `## ..anything..`
/// (in the ..anything.. case, `## ..anything..` is not included in the header)
pub fn parse_header(changelog: &str) -> Option<String> {
lazy_static::lazy_static! {
static ref FIRST_RE: Regex = Regex::new(r"(?s)^(# Changelog|# CHANGELOG|# changelog)(.*)(## Unreleased|## \[Unreleased\])").unwrap();
static ref SECOND_RE: Regex = Regex::new(r"(?s)^(# Changelog|# CHANGELOG|# changelog)(.*)(\n## )").unwrap();
}
if let Some(captures) = FIRST_RE.captures(changelog) {
return Some(format!("{}\n", &captures[0]));
}
if let Some(captures) = SECOND_RE.captures(changelog) {
return Some(format!("{}{}", &captures[1], &captures[2]));
}
None
}
pub fn last_changes(changelog: &str) -> anyhow::Result<Option<String>> {
last_changes_from_str(changelog)
}
pub fn last_changes_from_str(changelog: &str) -> anyhow::Result<Option<String>> {
let parser = ChangelogParser::new(changelog)?;
let last_release = parser.last_release().map(|r| r.notes.to_string());
Ok(last_release)
}
pub fn last_version_from_str(changelog: &str) -> anyhow::Result<Option<String>> {
let parser = ChangelogParser::new(changelog)?;
let last_release = parser.last_release().map(|r| r.version.to_string());
Ok(last_release)
}
pub fn last_release_from_str(changelog: &str) -> anyhow::Result<Option<ChangelogRelease>> {
let parser = ChangelogParser::new(changelog)?;
let last_release = parser.last_release().map(ChangelogRelease::from_release);
Ok(last_release)
}
pub struct ChangelogRelease {
title: String,
notes: String,
}
impl ChangelogRelease {
fn from_release(release: &parse_changelog::Release) -> Self {
Self {
title: release.title.to_string(),
notes: release.notes.to_string(),
}
}
pub fn title(&self) -> &str {
&self.title
}
pub fn notes(&self) -> &str {
&self.notes
}
}
pub struct ChangelogParser<'a> {
changelog: parse_changelog::Changelog<'a>,
}
impl<'a> ChangelogParser<'a> {
pub fn new(changelog_text: &'a str) -> anyhow::Result<Self> {
let changelog =
parse_changelog::parse(changelog_text).context("can't parse changelog")?;
Ok(Self { changelog })
}
fn last_release(&self) -> Option<&parse_changelog::Release> {
let last_release = release_at(&self.changelog, 0)?;
let last_release = if last_release.version.to_lowercase().contains("unreleased") {
release_at(&self.changelog, 1)?
} else {
last_release
};
Some(last_release)
}
}
fn release_at<'a>(
changelog: &'a parse_changelog::Changelog,
index: usize,
) -> Option<&'a parse_changelog::Release<'a>> {
let release = changelog.get_index(index)?.1;
Some(release)
}
#[cfg(test)]
mod tests {
use super::*;
fn last_changes_from_str_test(changelog: &str) -> String {
last_changes_from_str(changelog).unwrap().unwrap()
}
#[test]
fn changelog_header_is_parsed() {
let changelog = "\
# Changelog
My custom changelog header
## [Unreleased]
";
let header = parse_header(changelog).unwrap();
let expected_header = "\
# Changelog
My custom changelog header
## [Unreleased]
";
assert_eq!(header, expected_header);
}
#[test]
fn changelog_header_without_unreleased_is_parsed() {
let changelog = "\
# Changelog
My custom changelog header
## [0.2.5] - 2022-12-16
";
let header = parse_header(changelog).unwrap();
let expected_header = "\
# Changelog
My custom changelog header
";
assert_eq!(header, expected_header);
}
#[test]
fn changelog_header_with_versions_is_parsed() {
let changelog = "\
# Changelog
My custom changelog header
## [Unreleased]
## [0.2.5] - 2022-12-16
";
let header = parse_header(changelog).unwrap();
let expected_header = "\
# Changelog
My custom changelog header
## [Unreleased]
";
assert_eq!(header, expected_header);
}
#[test]
fn changelog_header_isnt_recognized() {
// A two-level header similar to `## [Unreleased]` is missing
let changelog = "\
# Changelog
My custom changelog header
";
let header = parse_header(changelog);
assert_eq!(header, None);
}
#[test]
fn changelog_with_unreleased_section_is_parsed() {
let changelog = "\
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.2.5] - 2022-12-16
### Added
- Add function to retrieve default branch (#372)
## [0.2.4] - 2022-12-12
### Changed
- improved error message
";
let changes = last_changes_from_str_test(changelog);
let expected_changes = "\
### Added
- Add function to retrieve default branch (#372)";
assert_eq!(changes, expected_changes);
}
#[test]
fn changelog_without_unreleased_section_is_parsed() {
let changelog = "\
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.5](https://github.com/MarcoIeni/release-plz/compare/git_cmd-v0.2.4...git_cmd-v0.2.5) - 2022-12-16
### Added
- Add function to retrieve default branch (#372)
## [0.2.4] - 2022-12-12
### Changed
- improved error message
";
let changes = last_changes_from_str_test(changelog);
let expected_changes = "\
### Added
- Add function to retrieve default branch (#372)";
assert_eq!(changes, expected_changes);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_release() {
let commits: Vec<&str> = Vec::new();
let changelog = ChangeLogBuilder::new(commits, "0.0.0")
.with_release_date(NaiveDate::from_ymd_opt(1995, 5, 15).unwrap())
.build();
let expected = r######"# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
"######;
pretty_assertions::assert_eq!(expected, &changelog.generate().unwrap())
}
#[test]
fn generates_changelog() {
let commits: Vec<&str> = vec![
"feat: some feature",
"some random commit",
"fix: some fix",
"chore(scope): some chore",
];
let changelog = ChangeLogBuilder::new(commits, "1.0.0")
.with_release_date(NaiveDate::from_ymd_opt(1995, 5, 15).unwrap())
.build();
let expected = r######"# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.0] - 1995-05-15
### Added
- some feature
### Fixed
- some fix
### Other
- some random commit
- *(scope)* some chore
"######;
pretty_assertions::assert_eq!(expected, &changelog.generate().unwrap())
}
}

View File

@@ -1,436 +1 @@
use std::{
io::Read,
ops::Deref,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use ::semver::Version;
use anyhow::Context;
use clap::{Args, Parser, Subcommand};
use cuddle_please_frontend::{gatheres::ConfigArgs, PleaseConfigBuilder};
use crate::{
cliff::{self, changelog_parser},
git_client::VcsClient,
gitea_client::GiteaClient,
ui::{ConsoleUi, DynUi},
versioning::{next_version::NextVersion, semver::get_most_significant_version},
};
type StdinFn = Option<Arc<Mutex<dyn Fn() -> anyhow::Result<String> + Send + Sync + 'static>>>;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
pub struct Command {
#[command(flatten)]
global: GlobalArgs,
#[command(flatten)]
config: ConfigArgs,
#[command(subcommand)]
commands: Option<Commands>,
#[clap(skip)]
ui: DynUi,
#[clap(skip)]
stdin: StdinFn,
}
#[derive(Args)]
struct GlobalArgs {
/// token is the personal access token from gitea.
#[arg(
env = "CUDDLE_PLEASE_TOKEN",
long,
long_help = "token is the personal access token from gitea. It requires at least repository write access, it isn't required by default, but for most usecases the flow will fail without it",
global = true,
help_heading = "Global"
)]
token: Option<String>,
/// whether to run in dry run mode (i.e. no pushes or releases)
#[arg(long, global = true, help_heading = "Global")]
dry_run: bool,
/// Inject configuration from stdin
#[arg(
env = "CUDDLE_PLEASE_CONFIG_STDIN",
long,
global = true,
help_heading = "Global",
long_help = "inject via stdin
cat <<EOF | cuddle-please --config-stdin
something
something
something
EOF
config-stdin will consume stdin until the channel is closed via. EOF"
)]
config_stdin: bool,
}
impl Default for Command {
fn default() -> Self {
Self::new()
}
}
impl Command {
pub fn new() -> Self {
let args = std::env::args();
Self::new_from_args_with_stdin(Some(ConsoleUi::default()), args, || {
let mut input = String::new();
std::io::stdin().read_to_string(&mut input)?;
Ok(input)
})
}
pub fn new_from_args<I, T, UIF>(ui: Option<UIF>, i: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
UIF: Into<DynUi>,
{
let mut s = Self::parse_from(i);
if let Some(ui) = ui {
s.ui = ui.into();
}
s
}
pub fn new_from_args_with_stdin<I, T, F, UIF>(ui: Option<UIF>, i: I, input: F) -> Self
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
F: Fn() -> anyhow::Result<String> + Send + Sync + 'static,
UIF: Into<DynUi>,
{
let mut s = Self::parse_from(i);
if let Some(ui) = ui {
s.ui = ui.into();
}
s.stdin = Some(Arc::new(Mutex::new(input)));
s
}
pub fn execute(self, current_dir: Option<&Path>) -> anyhow::Result<()> {
// 0. Get config
let mut builder = &mut PleaseConfigBuilder::new();
if self.global.config_stdin {
if let Some(stdin_fn) = self.stdin.clone() {
let output = (stdin_fn.lock().unwrap().deref())();
builder = builder.with_stdin(output?);
}
}
// 1. Parse the current directory
let current_dir = get_current_path(current_dir, self.config.source.clone())?;
let config = builder
.with_config_file(&current_dir)
.with_execution_env(std::env::vars())
.with_cli(self.config.clone())
.build()?;
match &self.commands {
Some(Commands::Release {}) => {
tracing::debug!("running bare command");
// 2. Parse the cuddle.please.yaml let cuddle.please.yaml take precedence
// 2a. if not existing use default.
// 2b. if not in a git repo abort. (unless --no-vcs is turned added)
let git_client = self.get_git(config.get_source())?;
// 3. Create gitea client and do a health check
let gitea_client = self.get_gitea_client();
gitea_client
.connect(config.get_owner(), config.get_repository())
.context("failed to connect to gitea repository")?;
// 4. Fetch git tags for the current repository
let tags = gitea_client.get_tags(config.get_owner(), config.get_repository())?;
let significant_tag = get_most_significant_version(tags.iter().collect());
// 5. Fetch git commits since last git tag
let commits = gitea_client.get_commits_since(
config.get_owner(),
config.get_repository(),
significant_tag.map(|st| st.commit.sha.clone()),
config.get_branch(),
)?;
// 7. Create a versioning client
let current_version = significant_tag
.map(|st| Version::try_from(st).unwrap())
.unwrap_or(Version::new(0, 1, 0));
// 8. Parse conventional commits and determine next version
let commit_strs = commits
.iter()
.map(|c| c.commit.message.as_str())
.collect::<Vec<&str>>();
if commit_strs.is_empty() {
tracing::info!("no commits to base release on");
return Ok(());
}
let next_version = current_version.next(&commit_strs);
// Compose changelog
let builder =
cliff::ChangeLogBuilder::new(&commit_strs, next_version.to_string()).build();
let changelog_placement = config.get_source().join("CHANGELOG.md");
let changelog = match std::fs::read_to_string(&changelog_placement).ok() {
Some(existing_changelog) => builder.prepend(existing_changelog)?,
None => builder.generate()?,
};
let changelog_last_changes = changelog_parser::last_changes(&changelog)?;
// 9b. check for release commit and release, if release exists continue
// 10b. create release
if let Some(first_commit) = commit_strs.first() {
if first_commit.contains("chore(release): ") {
if !self.global.dry_run {
gitea_client.create_release(
config.get_owner(),
config.get_repository(),
next_version.to_string(),
changelog_last_changes.unwrap(),
!next_version.pre.is_empty(),
)?;
} else {
tracing::debug!("creating release (dry_run)");
}
return Ok(());
}
}
// 9a. Create / Update Pr
// Create or update branch
git_client.checkout_branch()?;
std::fs::write(changelog_placement, changelog.as_bytes())?;
git_client.commit_and_push(next_version.to_string(), self.global.dry_run)?;
let _pr_number = match gitea_client
.get_pull_request(config.get_owner(), config.get_repository())?
{
Some(existing_pr) => {
if !self.global.dry_run {
gitea_client.update_pull_request(
config.get_owner(),
config.get_repository(),
next_version.to_string(),
changelog_last_changes.unwrap(),
existing_pr,
)?
} else {
tracing::debug!("updating pull request (dry_run)");
1
}
}
None => {
if !self.global.dry_run {
gitea_client.create_pull_request(
config.get_owner(),
config.get_repository(),
next_version.to_string(),
changelog,
config.get_branch(),
)?
} else {
tracing::debug!("creating pull request (dry_run)");
1
}
}
};
}
Some(Commands::Config { command }) => match command {
ConfigCommand::List { .. } => {
tracing::debug!("running command: config list");
self.ui.write_str_ln("cuddle-config");
}
},
Some(Commands::Gitea { command }) => {
let git_url = url::Url::parse(config.get_api_url())?;
let mut url = String::new();
url.push_str(git_url.scheme());
url.push_str("://");
url.push_str(&git_url.host().unwrap().to_string());
if let Some(port) = git_url.port() {
url.push_str(format!(":{port}").as_str());
}
let client = GiteaClient::new(url, self.global.token);
match command {
GiteaCommand::Connect {} => {
client.connect(config.get_owner(), config.get_repository())?;
self.ui.write_str_ln("connected succesfully go gitea");
}
GiteaCommand::Tags { command } => match command {
Some(GiteaTagsCommand::MostSignificant {}) => {
let tags =
client.get_tags(config.get_owner(), config.get_repository())?;
match get_most_significant_version(tags.iter().collect()) {
Some(tag) => {
self.ui.write_str_ln(&format!(
"found most significant tags: {}",
tag.name
));
}
None => {
self.ui.write_str_ln("found no tags with versioning schema");
}
}
}
None => {
let tags =
client.get_tags(config.get_owner(), config.get_repository())?;
self.ui.write_str_ln("got tags from gitea");
for tag in tags {
self.ui.write_str_ln(&format!("- {}", tag.name))
}
}
},
GiteaCommand::SinceCommit { sha, branch } => {
let commits = client.get_commits_since(
config.get_owner(),
config.get_repository(),
Some(sha),
branch,
)?;
self.ui.write_str_ln("got commits from gitea");
for commit in commits {
self.ui.write_str_ln(&format!("- {}", commit.get_title()))
}
}
GiteaCommand::CheckPr {} => {
let pr =
client.get_pull_request(config.get_owner(), config.get_repository())?;
match pr {
Some(index) => {
self.ui.write_str_ln(&format!(
"found cuddle-please (index={}) pr from gitea",
index
));
}
None => {
self.ui.write_str_ln("found no cuddle-please pr from gitea");
}
}
}
}
}
Some(Commands::Doctor {}) => {
match std::process::Command::new("git").arg("-v").output() {
Ok(o) => {
let stdout = std::str::from_utf8(&o.stdout).unwrap_or("");
self.ui.write_str_ln(&format!("OK: {}", stdout));
}
Err(e) => {
self.ui
.write_str_ln(&format!("WARNING: git is not installed: {}", e));
}
}
}
None => {}
}
Ok(())
}
fn get_git(&self, current_dir: &Path) -> anyhow::Result<VcsClient> {
VcsClient::new_git(current_dir)
}
fn get_gitea_client(&self) -> GiteaClient {
GiteaClient::new(
self.config.api_url.clone().expect("api_url to be set"),
self.global.token.clone(),
)
}
}
#[derive(Debug, Clone, Subcommand)]
enum Commands {
/// Config is mostly used for debugging the final config output
Release {},
#[command(hide = true)]
Config {
#[command(subcommand)]
command: ConfigCommand,
},
#[command(hide = true)]
Gitea {
#[command(subcommand)]
command: GiteaCommand,
},
/// Helps you identify missing things from your execution environment for cuddle-please to function as intended
Doctor {},
}
#[derive(Subcommand, Debug, Clone)]
enum ConfigCommand {
/// List will list the final configuration
List {},
}
#[derive(Subcommand, Debug, Clone)]
enum GiteaCommand {
Connect {},
Tags {
#[command(subcommand)]
command: Option<GiteaTagsCommand>,
},
SinceCommit {
#[arg(long)]
sha: String,
#[arg(long)]
branch: String,
},
CheckPr {},
}
#[derive(Subcommand, Debug, Clone)]
enum GiteaTagsCommand {
MostSignificant {},
}
fn get_current_path(
optional_current_dir: Option<&Path>,
optional_source_path: Option<PathBuf>,
) -> anyhow::Result<PathBuf> {
let path = optional_source_path
.or_else(|| optional_current_dir.map(|p| p.to_path_buf())) // fall back on current env from environment
.filter(|v| v.to_string_lossy() != "") // make sure we don't get empty values
//.and_then(|p| p.canonicalize().ok()) // Make sure we get the absolute path
//.context("could not find current dir, pass --source as a replacement")?;
.unwrap_or(PathBuf::from("."));
if !path.exists() {
anyhow::bail!("path doesn't exist {}", path.display());
}
Ok(path)
}

View File

@@ -1,76 +0,0 @@
use std::path::{Path, PathBuf};
#[derive(Clone, Debug, PartialEq)]
pub enum VcsClient {
Noop {},
Git { source: PathBuf },
}
impl VcsClient {
pub fn new_noop() -> VcsClient {
Self::Noop {}
}
pub fn new_git(path: &Path) -> anyhow::Result<VcsClient> {
if !path.to_path_buf().join(".git").exists() {
anyhow::bail!("git directory not found in: {}", path.display().to_string())
}
Ok(Self::Git {
source: path.to_path_buf(),
})
}
pub fn checkout_branch(&self) -> anyhow::Result<()> {
match self {
VcsClient::Noop {} => {}
VcsClient::Git { .. } => {
if let Err(_e) = self.exec_git(&["branch", "-D", "cuddle-please/release"]) {
tracing::debug!("failed to cleaned up local branch for force-push, this may be because it didn't exist before running this command");
}
self.exec_git(&["checkout", "-b", "cuddle-please/release"])?;
}
}
Ok(())
}
fn exec_git(&self, args: &[&str]) -> anyhow::Result<()> {
match self {
VcsClient::Noop {} => {}
VcsClient::Git { source } => {
let checkout_branch = std::process::Command::new("git")
.current_dir(source.as_path())
.args(args)
.output()?;
let stdout = std::str::from_utf8(&checkout_branch.stdout)?;
let stderr = std::str::from_utf8(&checkout_branch.stderr)?;
tracing::debug!(stdout = stdout, stderr = stderr, "git {}", args.join(" "));
}
}
Ok(())
}
pub fn commit_and_push(&self, version: impl Into<String>, dry_run: bool) -> anyhow::Result<()> {
match self {
VcsClient::Noop {} => {}
VcsClient::Git { .. } => {
self.exec_git(&["add", "."])?;
self.exec_git(&[
"commit",
"-m",
&format!("chore(release): {}", version.into()),
])?;
tracing::trace!("git push -u -f origin cuddle-please/release");
if !dry_run {
self.exec_git(&["push", "-u", "-f", "origin", "cuddle-please/release"])?;
}
}
}
Ok(())
}
}

View File

@@ -1,615 +0,0 @@
use anyhow::Context;
use reqwest::header::{HeaderMap, HeaderValue};
use semver::Version;
use serde::{Deserialize, Serialize};
#[allow(dead_code)]
pub struct GiteaClient {
url: String,
token: Option<String>,
pub allow_insecure: bool,
}
const APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
impl GiteaClient {
pub fn new(url: impl Into<String>, token: Option<impl Into<String>>) -> Self {
Self {
url: url.into(),
token: token.map(|t| t.into()),
allow_insecure: false,
}
}
fn create_client(&self) -> anyhow::Result<reqwest::blocking::Client> {
let cb = reqwest::blocking::ClientBuilder::new();
let mut header_map = HeaderMap::new();
if let Some(token) = &self.token {
header_map.insert(
"Authorization",
HeaderValue::from_str(format!("token {}", token).as_str())?,
);
}
let client = cb
.user_agent(APP_USER_AGENT)
.default_headers(header_map)
.danger_accept_invalid_certs(self.allow_insecure)
.build()?;
Ok(client)
}
pub fn connect(&self, owner: impl Into<String>, repo: impl Into<String>) -> anyhow::Result<()> {
let client = self.create_client()?;
let owner = owner.into();
let repo = repo.into();
tracing::trace!(owner = &owner, repo = &repo, "gitea connect");
let request = client
.get(format!(
"{}/api/v1/repos/{}/{}",
&self.url.trim_end_matches('/'),
owner,
repo
))
.build()?;
let resp = client.execute(request)?;
if !resp.status().is_success() {
resp.error_for_status()?;
return Ok(());
}
Ok(())
}
pub fn get_tags(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
) -> anyhow::Result<Vec<Tag>> {
let client = self.create_client()?;
let request = client
.get(format!(
"{}/api/v1/repos/{}/{}/tags",
&self.url.trim_end_matches('/'),
owner.into(),
repo.into()
))
.build()?;
let resp = client.execute(request)?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
}
let tags: Vec<Tag> = resp.json()?;
Ok(tags)
}
pub fn get_commits_since(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
since_sha: Option<impl Into<String>>,
branch: impl Into<String>,
) -> anyhow::Result<Vec<Commit>> {
let get_commits_since_page = |owner: &str,
repo: &str,
branch: &str,
page: usize|
-> anyhow::Result<(Vec<Commit>, bool)> {
let client = self.create_client()?;
tracing::trace!(
owner = owner,
repo = repo,
branch = branch,
page = page,
"fetching tags"
);
let request = client
.get(format!(
"{}/api/v1/repos/{}/{}/commits?page={}&limit={}&sha={}&stat=false&verification=false&files=false",
&self.url.trim_end_matches('/'),
owner,
repo,
page,
50,
branch,
))
.build()?;
let resp = client.execute(request)?;
let mut has_more = false;
if let Some(gitea_has_more) = resp.headers().get("X-HasMore") {
let gitea_has_more = gitea_has_more.to_str()?;
if gitea_has_more == "true" || gitea_has_more == "True" {
has_more = true;
}
}
if !resp.status().is_success() {
return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
}
let commits: Vec<Commit> = resp.json()?;
Ok((commits, has_more))
};
let commits =
self.get_commits_since_inner(owner, repo, since_sha, branch, get_commits_since_page)?;
Ok(commits)
}
fn get_commits_since_inner<F>(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
since_sha: Option<impl Into<String>>,
branch: impl Into<String>,
get_commits: F,
) -> anyhow::Result<Vec<Commit>>
where
F: Fn(&str, &str, &str, usize) -> anyhow::Result<(Vec<Commit>, bool)>,
{
let mut commits = Vec::new();
let mut page = 1;
let owner: String = owner.into();
let repo: String = repo.into();
let since_sha: Option<String> = since_sha.map(|ss| ss.into());
let branch: String = branch.into();
let mut found_commit = false;
loop {
let (new_commits, has_more) = get_commits(&owner, &repo, &branch, page)?;
for commit in new_commits {
if let Some(since_sha) = &since_sha {
if commit.sha.contains(since_sha) {
found_commit = true;
} else if !found_commit {
commits.push(commit);
}
} else {
commits.push(commit);
}
}
if !has_more {
break;
}
page += 1;
}
if !found_commit && since_sha.is_some() {
return Err(anyhow::anyhow!(
"sha was not found in commit chain: {} on branch: {}",
since_sha.unwrap_or("".into()),
branch
));
}
Ok(commits)
}
pub fn get_pull_request(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
) -> anyhow::Result<Option<usize>> {
let request_pull_request =
|owner: &str, repo: &str, page: usize| -> anyhow::Result<(Vec<PullRequest>, bool)> {
let client = self.create_client()?;
tracing::trace!(owner = owner, repo = repo, "fetching pull-requests");
let request = client
.get(format!(
"{}/api/v1/repos/{}/{}/pulls?state=open&sort=recentupdate&page={}&limit={}",
&self.url.trim_end_matches('/'),
owner,
repo,
page,
50,
))
.build()?;
let resp = client.execute(request)?;
let mut has_more = false;
if let Some(gitea_has_more) = resp.headers().get("X-HasMore") {
let gitea_has_more = gitea_has_more.to_str()?;
if gitea_has_more == "true" || gitea_has_more == "True" {
has_more = true;
}
}
if !resp.status().is_success() {
return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
}
let commits: Vec<PullRequest> = resp.json()?;
Ok((commits, has_more))
};
self.get_pull_request_inner(owner, repo, request_pull_request)
}
fn get_pull_request_inner<F>(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
request_pull_request: F,
) -> anyhow::Result<Option<usize>>
where
F: Fn(&str, &str, usize) -> anyhow::Result<(Vec<PullRequest>, bool)>,
{
let mut page = 1;
let owner: String = owner.into();
let repo: String = repo.into();
loop {
let (pull_requests, has_more) = request_pull_request(&owner, &repo, page)?;
for pull_request in pull_requests {
if pull_request.head.r#ref.contains("cuddle-please/release") {
return Ok(Some(pull_request.number));
}
}
if !has_more {
break;
}
page += 1;
}
Ok(None)
}
pub fn create_pull_request(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
version: impl Into<String>,
body: impl Into<String>,
base: impl Into<String>,
) -> anyhow::Result<usize> {
#[derive(Clone, Debug, Serialize, Deserialize)]
struct CreatePullRequestOption {
base: String,
body: String,
head: String,
title: String,
}
let client = self.create_client()?;
let owner = owner.into();
let repo = repo.into();
let version = version.into();
let body = body.into();
let base = base.into();
let request = CreatePullRequestOption {
base: base.clone(),
body: body.clone(),
head: "cuddle-please/release".into(),
title: format!("chore(release): {}", version),
};
tracing::trace!(
owner = owner,
repo = repo,
version = version,
base = base,
"create pull_request"
);
let request = client
.post(format!(
"{}/api/v1/repos/{}/{}/pulls",
&self.url.trim_end_matches('/'),
owner,
repo,
))
.json(&request)
.build()?;
let resp = client.execute(request)?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
}
let commits: PullRequest = resp.json()?;
Ok(commits.number)
}
pub fn update_pull_request(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
version: impl Into<String>,
body: impl Into<String>,
index: usize,
) -> anyhow::Result<usize> {
#[derive(Clone, Debug, Serialize, Deserialize)]
struct CreatePullRequestOption {
body: String,
title: String,
}
let client = self.create_client()?;
let owner = owner.into();
let repo = repo.into();
let version = version.into();
let body = body.into();
let request = CreatePullRequestOption {
body: body.clone(),
title: format!("chore(release): {}", version),
};
tracing::trace!(
owner = owner,
repo = repo,
version = version,
"update pull_request"
);
let request = client
.patch(format!(
"{}/api/v1/repos/{}/{}/pulls/{}",
&self.url.trim_end_matches('/'),
owner,
repo,
index
))
.json(&request)
.build()?;
let resp = client.execute(request)?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
}
let commits: PullRequest = resp.json()?;
Ok(commits.number)
}
pub fn create_release(
&self,
owner: impl Into<String>,
repo: impl Into<String>,
version: impl Into<String>,
body: impl Into<String>,
prerelease: bool,
) -> anyhow::Result<Release> {
#[derive(Clone, Debug, Serialize, Deserialize)]
struct CreateReleaseOption {
body: String,
draft: bool,
name: String,
prerelease: bool,
#[serde(alias = "tag_name")]
tag_name: String,
}
let client = self.create_client()?;
let owner = owner.into();
let repo = repo.into();
let version = version.into();
let body = body.into();
let request = CreateReleaseOption {
body,
draft: false,
name: version.clone(),
prerelease,
tag_name: version.clone(),
};
tracing::trace!(
owner = owner,
repo = repo,
version = version,
"create release"
);
let request = client
.post(format!(
"{}/api/v1/repos/{}/{}/releases",
&self.url.trim_end_matches('/'),
owner,
repo,
))
.json(&request)
.build()?;
let resp = client.execute(request)?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!(resp.error_for_status().unwrap_err()));
}
let release: Release = resp.json()?;
Ok(release)
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Release {
id: usize,
url: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct PullRequest {
number: usize,
head: PRBranchInfo,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct PRBranchInfo {
#[serde(alias = "ref")]
r#ref: String,
label: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Commit {
sha: String,
pub created: String,
pub commit: CommitDetails,
}
impl Commit {
pub fn get_title(&self) -> String {
self.commit
.message
.split('\n')
.take(1)
.collect::<Vec<&str>>()
.join("\n")
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct CommitDetails {
pub message: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Tag {
pub id: String,
pub message: String,
pub name: String,
pub commit: TagCommit,
}
impl TryFrom<Tag> for Version {
type Error = anyhow::Error;
fn try_from(value: Tag) -> Result<Self, Self::Error> {
tracing::trace!(name = &value.name, "parsing tag into version");
value
.name
.parse::<Version>()
.context("could not get version from tag")
}
}
impl TryFrom<&Tag> for Version {
type Error = anyhow::Error;
fn try_from(value: &Tag) -> Result<Self, Self::Error> {
tracing::trace!(name = &value.name, "parsing tag into version");
value
.name
.parse::<Version>()
.context("could not get version from tag")
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct TagCommit {
pub created: String,
pub sha: String,
pub url: String,
}
#[cfg(test)]
mod test {
use tracing_test::traced_test;
use crate::gitea_client::{Commit, CommitDetails};
use super::GiteaClient;
fn get_api_res() -> Vec<Vec<Commit>> {
let api_results = vec![
vec![Commit {
sha: "first-sha".into(),
created: "".into(),
commit: CommitDetails {
message: "first-message".into(),
},
}],
vec![Commit {
sha: "second-sha".into(),
created: "".into(),
commit: CommitDetails {
message: "second-message".into(),
},
}],
vec![Commit {
sha: "third-sha".into(),
created: "".into(),
commit: CommitDetails {
message: "third-message".into(),
},
}],
];
api_results
}
fn get_commits(sha: String) -> anyhow::Result<(Vec<Vec<Commit>>, Vec<Commit>)> {
let api_res = get_api_res();
let client = GiteaClient::new("", Some(""));
let commits = client.get_commits_since_inner(
"owner",
"repo",
Some(sha),
"some-branch",
|_, _, _, page| -> anyhow::Result<(Vec<Commit>, bool)> {
let commit_page = api_res.get(page - 1).unwrap();
Ok((commit_page.clone(), page != 3))
},
)?;
Ok((api_res, commits))
}
#[test]
#[traced_test]
fn finds_tag_in_list() {
let (expected, actual) = get_commits("second-sha".into()).unwrap();
assert_eq!(
expected.get(0).unwrap().clone().as_slice(),
actual.as_slice()
);
}
#[test]
#[traced_test]
fn finds_tag_in_list_already_newest_commit() {
let (_, actual) = get_commits("first-sha".into()).unwrap();
assert_eq!(0, actual.len());
}
#[test]
#[traced_test]
fn finds_tag_in_list_is_base() {
let (expected, actual) = get_commits("third-sha".into()).unwrap();
assert_eq!(expected[0..=1].concat().as_slice(), actual.as_slice());
}
#[test]
#[traced_test]
fn finds_didnt_find_tag_in_list() {
let error = get_commits("not-found-sha".into()).unwrap_err();
assert_eq!(
"sha was not found in commit chain: not-found-sha on branch: some-branch",
error.to_string()
);
}
}

View File

@@ -1,6 +0,0 @@
pub mod cliff;
pub mod command;
pub mod git_client;
pub mod gitea_client;
pub mod ui;
pub mod versioning;

View File

@@ -1,11 +1,4 @@
pub mod cliff;
pub mod command;
pub mod git_client;
pub mod gitea_client;
pub mod ui;
pub mod versioning;
use command::Command;
use cuddle_please_commands::PleaseCommand;
fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
@@ -14,7 +7,7 @@ fn main() -> anyhow::Result<()> {
let current_dir = std::env::current_dir().ok();
let current_dir = current_dir.as_deref();
Command::new().execute(current_dir)?;
PleaseCommand::new().execute(current_dir)?;
Ok(())
}

View File

@@ -1,49 +0,0 @@
pub trait Ui {
fn write_str(&self, content: &str);
fn write_err_str(&self, content: &str);
fn write_str_ln(&self, content: &str);
fn write_err_str_ln(&self, content: &str);
}
pub type DynUi = Box<dyn Ui + Send + Sync>;
impl Default for DynUi {
fn default() -> Self {
Box::<ConsoleUi>::default()
}
}
#[derive(Default)]
pub(crate) struct ConsoleUi {}
#[allow(dead_code)]
impl ConsoleUi {
pub fn new() -> Self {
Self::default()
}
}
impl From<ConsoleUi> for DynUi {
fn from(value: ConsoleUi) -> Self {
Box::new(value)
}
}
impl Ui for ConsoleUi {
fn write_str(&self, content: &str) {
print!("{}", content)
}
fn write_err_str(&self, content: &str) {
eprint!("{}", content)
}
fn write_str_ln(&self, content: &str) {
println!("{}", content)
}
fn write_err_str_ln(&self, content: &str) {
eprintln!("{}", content)
}
}

View File

@@ -1,187 +0,0 @@
use conventional_commit_parser::commit::{CommitType, ConventionalCommit};
use semver::Version;
#[derive(Debug, Clone, PartialEq)]
pub enum VersionIncrement {
Major,
Minor,
Patch,
Prerelease,
}
impl VersionIncrement {
pub fn from<C>(cur_version: &Version, commits: C) -> Option<Self>
where
C: IntoIterator,
C::Item: AsRef<str>,
{
let mut commits = commits.into_iter().peekable();
commits.peek()?;
if let Some(prerelease) = Self::is_prerelease(cur_version) {
return Some(prerelease);
}
let commits: Vec<ConventionalCommit> = Self::parse_commits::<C>(commits);
Some(Self::from_conventional_commits(commits))
}
#[inline]
fn parse_commits<C>(
commits: std::iter::Peekable<<C as IntoIterator>::IntoIter>,
) -> Vec<ConventionalCommit>
where
C: IntoIterator,
C::Item: AsRef<str>,
{
commits
.filter_map(|c| conventional_commit_parser::parse(c.as_ref()).ok())
.collect()
}
// Find most significant change
fn from_conventional_commits(commits: Vec<ConventionalCommit>) -> VersionIncrement {
let found_breaking = || commits.iter().any(|c| c.is_breaking_change);
let found_feature = || {
commits
.iter()
.any(|c| matches!(c.commit_type, CommitType::Feature))
};
match (found_breaking(), found_feature()) {
(true, _) => Self::Major,
(_, true) => Self::Minor,
(_, false) => Self::Patch,
}
}
fn is_prerelease(cur_version: &Version) -> Option<VersionIncrement> {
if !cur_version.pre.is_empty() {
return Some(Self::Prerelease);
}
None
}
}
#[cfg(test)]
mod tests {
use crate::versioning::conventional_parse::VersionIncrement;
use semver::Version;
use tracing_test::traced_test;
#[test]
#[traced_test]
fn is_prerelease() {
let version = Version::parse("0.0.0-alpha.1").unwrap();
let commits = vec![
"feat: something",
"fix: something",
"feat(something): something",
"feat(breaking): some
BREAKING CHANGE: something",
];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Prerelease, actual);
}
#[test]
#[traced_test]
fn is_patch() {
let version = Version::parse("0.0.1").unwrap();
let commits = vec![
"fix: something",
"fix: something",
"fix: something",
"fix: something",
"fix: something",
"fix: something",
];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Patch, actual);
}
#[test]
#[traced_test]
fn is_minor() {
let version = Version::parse("0.1.0").unwrap();
let commits = vec![
"feat: something",
"feat: something",
"fix: something",
"fix: something",
"fix: something",
"fix: something",
];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Minor, actual);
}
#[test]
#[traced_test]
fn is_major() {
let version = Version::parse("0.1.0").unwrap();
let commits = vec![
"feat: something",
"feat: something
BREAKING CHANGE: something",
"fix: something",
"fix: something",
"fix: something",
"fix: something",
];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Major, actual);
}
#[test]
#[traced_test]
fn chore_is_patch() {
let version = Version::parse("0.1.0").unwrap();
let commits = vec!["chore: something"];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Patch, actual);
}
#[test]
#[traced_test]
fn refactor_is_patch() {
let version = Version::parse("0.1.0").unwrap();
let commits = vec!["refactor: something"];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Patch, actual);
}
#[test]
#[traced_test]
fn unknown_commits_are_patch() {
let version = Version::parse("0.1.0").unwrap();
let commits = vec!["blablabla some commit"];
let actual = VersionIncrement::from(&version, commits).unwrap();
assert_eq!(VersionIncrement::Patch, actual);
}
#[test]
#[traced_test]
fn nothing_returns_none() {
let version = Version::parse("0.1.0").unwrap();
let commits: Vec<&str> = Vec::new();
let actual = VersionIncrement::from(&version, commits).is_none();
assert!(actual);
}
}

View File

@@ -1,3 +0,0 @@
pub mod conventional_parse;
pub mod next_version;
pub mod semver;

View File

@@ -1,170 +0,0 @@
use semver::{Prerelease, Version};
use super::conventional_parse::VersionIncrement;
pub trait NextVersion {
fn next<I>(&self, commits: I) -> Self
where
I: IntoIterator,
I::Item: AsRef<str>;
}
impl NextVersion for Version {
fn next<I>(&self, commits: I) -> Self
where
I: IntoIterator,
I::Item: AsRef<str>,
{
let increment = VersionIncrement::from(self, commits);
match increment {
Some(increment) => match increment {
VersionIncrement::Major => Self {
major: self.major + 1,
minor: 0,
patch: 0,
pre: Prerelease::EMPTY,
..self.clone()
},
VersionIncrement::Minor => Self {
minor: self.minor + 1,
patch: 0,
pre: Prerelease::EMPTY,
..self.clone()
},
VersionIncrement::Patch => Self {
patch: self.patch + 1,
pre: Prerelease::EMPTY,
..self.clone()
},
VersionIncrement::Prerelease => Self {
pre: {
let release = &self.pre;
let release_version = match release.rsplit_once('.') {
Some((tag, version)) => match version.parse::<usize>() {
Ok(version) => format!("{tag}.{}", version + 1),
Err(_) => format!("{tag}.1"),
},
None => format!("{release}.1"),
};
Prerelease::new(&release_version).expect("prerelease is not valid semver")
},
..self.clone()
},
},
None => self.clone(),
}
}
}
#[cfg(test)]
mod test {
use semver::Version;
use tracing_test::traced_test;
use crate::versioning::next_version::NextVersion;
#[test]
#[traced_test]
fn is_no_bump() {
let version = Version::parse("0.0.0-prerelease").unwrap();
let commits: Vec<&str> = vec![];
let actual = version.next(commits);
assert_eq!("0.0.0-prerelease", actual.to_string())
}
#[test]
#[traced_test]
fn is_prerelease_initial() {
let version = Version::parse("0.0.0-prerelease").unwrap();
let commits: Vec<&str> = vec!["feat: something"];
let actual = version.next(commits);
assert_eq!("0.0.0-prerelease.1", actual.to_string())
}
#[test]
#[traced_test]
fn is_prerelease_invalid() {
let version = Version::parse("0.0.0-prerelease.invalid").unwrap();
let commits: Vec<&str> = vec!["feat: something"];
let actual = version.next(commits);
assert_eq!("0.0.0-prerelease.1", actual.to_string())
}
#[test]
#[traced_test]
fn is_prerelease_next() {
let version = Version::parse("0.0.0-prerelease.1").unwrap();
let commits: Vec<&str> = vec!["feat: something"];
let actual = version.next(commits);
assert_eq!("0.0.0-prerelease.2", actual.to_string())
}
#[test]
#[traced_test]
fn is_patch() {
let version = Version::parse("0.0.0").unwrap();
let commits: Vec<&str> = vec!["fix: something"];
let actual = version.next(commits);
assert_eq!("0.0.1", actual.to_string())
}
#[test]
#[traced_test]
fn is_minor() {
let version = Version::parse("0.1.0").unwrap();
let commits: Vec<&str> = vec!["feat: something"];
let actual = version.next(commits);
assert_eq!("0.2.0", actual.to_string())
}
#[test]
#[traced_test]
fn is_minor_clears_patch() {
let version = Version::parse("0.1.1").unwrap();
let commits: Vec<&str> = vec!["feat: something"];
let actual = version.next(commits);
assert_eq!("0.2.0", actual.to_string())
}
#[test]
#[traced_test]
fn is_major() {
let version = Version::parse("0.0.0").unwrap();
let commits: Vec<&str> = vec![
"feat: something
BREAKING CHANGE: something",
];
let actual = version.next(commits);
assert_eq!("1.0.0", actual.to_string())
}
#[test]
#[traced_test]
fn is_major_clears_minor_patch() {
let version = Version::parse("1.2.3").unwrap();
let commits: Vec<&str> = vec![
"feat: something
BREAKING CHANGE: something",
];
let actual = version.next(commits);
assert_eq!("2.0.0", actual.to_string())
}
}

View File

@@ -1,159 +0,0 @@
use std::cmp::Reverse;
use crate::gitea_client::Tag;
use semver::Version;
pub fn get_most_significant_version<'a>(tags: Vec<&'a Tag>) -> Option<&'a Tag> {
let mut versions: Vec<(&'a Tag, Version)> = tags
.into_iter()
.filter_map(|c| {
if let Ok(version) = c.name.trim_start_matches('v').parse::<Version>() {
Some((c, version))
} else {
None
}
})
.collect();
versions.sort_unstable_by_key(|(_, version)| Reverse(version.clone()));
let tag = versions.first().map(|(tag, _)| *tag);
if let Some(tag) = tag {
tracing::trace!(name = &tag.name, "found most significant tag with version");
}
tag
}
#[cfg(test)]
mod test {
use tracing_test::traced_test;
use crate::{
gitea_client::{Tag, TagCommit},
versioning::semver::get_most_significant_version,
};
fn create_tag(version: impl Into<String>) -> Tag {
let version = version.into();
Tag {
id: "some-id".into(),
message: version.clone(),
name: version,
commit: TagCommit {
created: "date".into(),
sha: "sha".into(),
url: "url".into(),
},
}
}
#[test]
#[traced_test]
fn gets_most_significant_version() {
let most_significant = create_tag("3.1.1");
let tags = vec![
create_tag("1.0.1"),
create_tag("1.2.1"),
most_significant.clone(),
create_tag("0.0.1"),
create_tag("0.0.2"),
];
let actual = get_most_significant_version(tags.iter().collect()).unwrap();
assert_eq!(&most_significant, actual)
}
#[test]
#[traced_test]
fn gets_most_significant_version_patch() {
let most_significant = create_tag("0.0.8");
let tags = vec![
create_tag("0.0.1"),
create_tag("0.0.7"),
create_tag("0.0.2"),
most_significant.clone(),
create_tag("0.0.0"),
];
let actual = get_most_significant_version(tags.iter().collect()).unwrap();
assert_eq!(&most_significant, actual)
}
#[test]
#[traced_test]
fn gets_most_significant_version_minor() {
let most_significant = create_tag("0.8.0");
let tags = vec![
create_tag("0.1.1"),
create_tag("0.2.7"),
create_tag("0.7.2"),
most_significant.clone(),
create_tag("0.3.0"),
];
let actual = get_most_significant_version(tags.iter().collect()).unwrap();
assert_eq!(&most_significant, actual)
}
#[test]
#[traced_test]
fn gets_most_significant_version_major() {
let most_significant = create_tag("7.8.0");
let tags = vec![
create_tag("6.1.1"),
create_tag("1.2.7"),
create_tag("2.7.2"),
most_significant.clone(),
create_tag("3.3.0"),
];
let actual = get_most_significant_version(tags.iter().collect()).unwrap();
assert_eq!(&most_significant, actual)
}
#[test]
#[traced_test]
fn ignored_invalid_tags() {
let tags = vec![
create_tag("something-3.3.0"),
create_tag("bla bla bla"),
create_tag("main"),
create_tag("master"),
create_tag("develop"),
];
let actual = get_most_significant_version(tags.iter().collect()).is_none();
assert!(actual)
}
#[test]
#[traced_test]
fn mix_v_prefix() {
let most_significant = create_tag("v7.8.0");
let tags = vec![
create_tag("6.1.1"),
create_tag("v1.2.7"),
create_tag("2.7.2"),
most_significant.clone(),
create_tag("v3.3.0"),
];
let actual = get_most_significant_version(tags.iter().collect()).unwrap();
assert_eq!(&most_significant, actual)
}
#[test]
#[traced_test]
fn mix_v_prefix_2() {
let most_significant = create_tag("7.8.0");
let tags = vec![
create_tag("6.1.1"),
create_tag("v1.2.7"),
create_tag("2.7.2"),
most_significant.clone(),
create_tag("v3.3.0"),
];
let actual = get_most_significant_version(tags.iter().collect()).unwrap();
assert_eq!(&most_significant, actual)
}
}

View File

@@ -1,4 +1,4 @@
use cuddle_please::ui::{DynUi, Ui};
use cuddle_please_misc::{DynUi, Ui};
use std::{
io::Write,

View File

@@ -1,7 +1,7 @@
pub mod common;
use common::BufferUi;
use cuddle_please::command::Command;
use cuddle_please_commands::PleaseCommand;
use tracing_test::traced_test;
use crate::common::{assert_output, get_test_data_path};
@@ -17,7 +17,7 @@ fn test_config_from_current_dir() {
let ui = &BufferUi::default();
let current_dir = get_test_data_path("cuddle-embed");
Command::new_from_args(Some(ui), args)
PleaseCommand::new_from_args(Some(ui), args)
.execute(Some(&current_dir))
.unwrap();
@@ -33,7 +33,7 @@ fn test_config_from_source_dir() {
args.push("--source");
args.push(current_dir.to_str().unwrap());
Command::new_from_args(Some(ui), args)
PleaseCommand::new_from_args(Some(ui), args)
.execute(None)
.unwrap();
@@ -55,7 +55,7 @@ settings:
"#;
args.push("--config-stdin");
Command::new_from_args_with_stdin(Some(ui), args, || Ok(config.into()))
PleaseCommand::new_from_args_with_stdin(Some(ui), args, || Ok(config.into()))
.execute(None)
.unwrap();
assert_output(ui, "cuddle-config\n", "");
@@ -67,7 +67,7 @@ fn test_config_fails_when_not_path_is_set() {
let args = get_base_args();
let ui = &BufferUi::default();
let res = Command::new_from_args(Some(ui), args).execute(None);
let res = PleaseCommand::new_from_args(Some(ui), args).execute(None);
assert!(res.is_err())
}

View File

@@ -1,6 +1,6 @@
use std::path::Path;
use cuddle_please::git_client::VcsClient;
use cuddle_please_misc::VcsClient;
use pretty_assertions::assert_eq;
use tracing_test::traced_test;

View File

@@ -1,6 +1,6 @@
pub mod common;
use cuddle_please::git_client::VcsClient;
use cuddle_please_misc::VcsClient;
use tracing_test::traced_test;
use crate::common::get_test_data_path;