diff --git a/Cargo.lock b/Cargo.lock index 0d9876a..2825466 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -32,6 +44,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -101,6 +119,12 @@ version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "autocfg" version = "1.2.0" @@ -390,6 +414,18 @@ dependencies = [ "url", ] +[[package]] +name = "cuddle-please-actions" +version = "0.1.0" +dependencies = [ + "anyhow", + "pretty_assertions", + "semver", + "toml_edit", + "tracing", + "yaml-rust2", +] + [[package]] name = "cuddle-please-commands" version = "0.1.0" @@ -398,6 +434,7 @@ dependencies = [ "chrono", "clap", "conventional_commit_parser", + "cuddle-please-actions", "cuddle-please-frontend", "cuddle-please-misc", "dotenv", @@ -779,6 +816,19 @@ name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown", +] [[package]] name = "heck" @@ -2743,12 +2793,43 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yaml-rust2" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498f4d102a79ea1c9d4dd27573c0fc96ad74c023e8da38484e47883076da25fb" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + [[package]] name = "yansi" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" +[[package]] +name = "zerocopy" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.58", +] + [[package]] name = "zeroize" version = "1.7.0" diff --git a/Cargo.toml b/Cargo.toml index c1749b1..7bcf83b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,6 @@ [workspace] members = [ - "crates/cuddle-please", - "crates/cuddle-please-frontend", - "crates/cuddle-please-commands", - "crates/cuddle-please-misc", - "crates/cuddle-please-release-strategy" + "crates/*" ] resolver = "2" @@ -14,6 +10,7 @@ cuddle-please-frontend = { path = "crates/cuddle-please-frontend", version = "0. cuddle-please-commands = { path = "crates/cuddle-please-commands", version = "0.1.0" } cuddle-please-misc = { path = "crates/cuddle-please-misc", version = "0.1.0" } cuddle-please-release-strategy = { path = "crates/cuddle-please-release-strategy", version = "0.1.0" } +cuddle-please-actions = { path = "crates/cuddle-please-actions", version = "0.1.0" } anyhow = { version = "1.0.81" } tracing = { version = "0.1", features = ["log"] } @@ -22,6 +19,7 @@ clap = { version = "4.5.4", features = ["derive", "env"] } dotenv = { version = "0.15.0" } url = { version = "2.5.0" } serde_yaml = { version = "0.9.34+deprecated" } +yaml-rust2 = {version = "0.8.0"} serde = { version = "1", features = ["derive"] } semver = "1.0.22" conventional_commit_parser = "0.9.4" @@ -32,6 +30,7 @@ regex = "1.10.4" chrono = "0.4.37" lazy_static = "1.4.0" parse-changelog = "0.6.6" +toml_edit = "0.22.9" tracing-test = "0.2" pretty_assertions = "1.4" diff --git a/crates/cuddle-please-actions/Cargo.toml b/crates/cuddle-please-actions/Cargo.toml new file mode 100644 index 0000000..be17029 --- /dev/null +++ b/crates/cuddle-please-actions/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cuddle-please-actions" +description = "A release-please inspired release manager tool, built on top of cuddle, but also useful standalone, cuddle-please supports, your ci of choice, as well as gitea, github" +repository = "https://git.front.kjuulh.io/kjuulh/cuddle-please" +readme = "../../README.md" +license-file = "../../LICENSE" +version = "0.1.0" +edition = "2021" +publishable = true + +[dependencies] +anyhow.workspace = true +tracing.workspace = true +semver.workspace = true +toml_edit.workspace = true +yaml-rust2.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true diff --git a/crates/cuddle-please-actions/src/actions.rs b/crates/cuddle-please-actions/src/actions.rs new file mode 100644 index 0000000..e545482 --- /dev/null +++ b/crates/cuddle-please-actions/src/actions.rs @@ -0,0 +1,9 @@ +use semver::Version; + +use crate::ActionConfig; + +pub trait Action { + fn enabled(&self, config: &ActionConfig) -> anyhow::Result; + fn name(&self) -> String; + fn execute(&self, version: &Version) -> anyhow::Result<()>; +} diff --git a/crates/cuddle-please-actions/src/config.rs b/crates/cuddle-please-actions/src/config.rs new file mode 100644 index 0000000..abcbae0 --- /dev/null +++ b/crates/cuddle-please-actions/src/config.rs @@ -0,0 +1,49 @@ +use std::ops::Deref; + +use anyhow::Context; + +pub enum ActionConfig { + Actual { doc: yaml_rust2::Yaml }, + None, +} + +impl Deref for ActionConfig { + type Target = yaml_rust2::Yaml; + + fn deref(&self) -> &Self::Target { + match &self { + ActionConfig::Actual { doc } => doc, + ActionConfig::None => &yaml_rust2::Yaml::BadValue, + } + } +} + +impl TryFrom<&str> for ActionConfig { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + let mut cuddle = yaml_rust2::YamlLoader::load_from_str(value)?; + + if cuddle.len() != 1 { + anyhow::bail!("cuddle.yaml can only be 1 document wide"); + } + + let doc = cuddle.pop().unwrap(); + let doc = doc["please"]["actions"].clone(); + + if doc.is_badvalue() { + return Ok(Self::None); + } + + Ok(Self::Actual { doc }) + } +} + +impl ActionConfig { + pub fn parse() -> anyhow::Result { + let cuddle_yaml = + std::fs::read_to_string("cuddle.yaml").context("failed to read cuddle.yaml")?; + + Self::try_from(cuddle_yaml.as_str()) + } +} diff --git a/crates/cuddle-please-actions/src/lib.rs b/crates/cuddle-please-actions/src/lib.rs new file mode 100644 index 0000000..61374b0 --- /dev/null +++ b/crates/cuddle-please-actions/src/lib.rs @@ -0,0 +1,45 @@ +pub(crate) mod actions; +mod config; +mod rust_action; + +use std::{ops::Deref, sync::Arc}; + +use catalog::RustAction; +pub use config::ActionConfig; +use semver::Version; +pub mod catalog { + pub use crate::rust_action::*; +} + +pub struct Action(Arc); + +impl Deref for Action { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +pub struct Actions(Vec); + +impl Actions { + pub fn from_cuddle() -> anyhow::Result { + let config = ActionConfig::parse()?; + + Ok(Self( + vec![Action(Arc::new(RustAction::new()))] + .into_iter() + .filter(|a| a.enabled(&config).unwrap_or_default()) + .collect(), + )) + } + + pub fn execute(&self, version: &Version) -> anyhow::Result<()> { + for action in &self.0 { + action.execute(version)?; + } + + Ok(()) + } +} diff --git a/crates/cuddle-please-actions/src/rust_action.rs b/crates/cuddle-please-actions/src/rust_action.rs new file mode 100644 index 0000000..25556a5 --- /dev/null +++ b/crates/cuddle-please-actions/src/rust_action.rs @@ -0,0 +1,237 @@ +use std::io::Write; + +use anyhow::Context; + +use crate::{actions::Action, ActionConfig}; + +#[derive(Default, Clone)] +pub struct RustAction {} + +impl RustAction { + pub fn new() -> Self { + Self {} + } + + fn execute_content( + &self, + version: &semver::Version, + cargo_content: &str, + ) -> anyhow::Result { + tracing::trace!("parsing Cargo.toml file as tolm"); + let mut cargo_doc = cargo_content.parse::()?; + + tracing::debug!( + "updating cargo workspace package version to {}", + version.to_string() + ); + + let workspace = if cargo_doc.contains_table("workspace") { + cargo_doc["workspace"].as_table_mut().unwrap() + } else { + let mut t = toml_edit::Table::new(); + t.set_implicit(true); + cargo_doc["workspace"] = toml_edit::Item::Table(t); + cargo_doc["workspace"].as_table_mut().unwrap() + }; + let package = workspace["package"].or_insert(toml_edit::table()); + package["version"] = toml_edit::value(version.to_string()); + + Ok(cargo_doc.to_string()) + } +} + +impl Action for RustAction { + fn enabled(&self, config: &ActionConfig) -> anyhow::Result { + if let Ok(v) = std::env::var("CUDDLE_PLEASE_RUST_ACTION") { + if let Ok(true) = v.parse::() { + return Ok(true); + } + } + + let val = &config[self.name().as_str()]; + + if val.is_badvalue() { + return Ok(false); + } + + Ok(val.as_bool().unwrap_or(true)) + } + + fn name(&self) -> String { + "rust".into() + } + + fn execute(&self, version: &semver::Version) -> anyhow::Result<()> { + tracing::info!( + "running rust action for version: {} and file: Cargo.toml", + version.to_string() + ); + + let path = std::path::PathBuf::from("Cargo.toml"); + + tracing::trace!("reading Cargo.toml"); + + let file = match std::fs::read_to_string(&path) { + Ok(file) => file, + Err(e) => match e.kind() { + std::io::ErrorKind::NotFound => { + anyhow::bail!("err: Cargo.toml was not found in dir") + } + _ => Err(e)?, + }, + }; + + let cargo_doc = self.execute_content(version, &file)?; + + let mut cargo_file = std::fs::File::create(&path)?; + cargo_file.write_all(cargo_doc.as_bytes())?; + cargo_file.sync_all()?; + + tracing::debug!("finished writing cargo file"); + + Ok(()) + } +} + +#[cfg(test)] +mod test { + use semver::{BuildMetadata, Prerelease}; + + use crate::{actions::Action, ActionConfig}; + + use super::RustAction; + + #[test] + fn test_is_enabled() { + let config = ActionConfig::try_from( + r#" +please: + actions: + rust: true + "#, + ) + .unwrap(); + + let enabled = RustAction::new().enabled(&config).unwrap(); + + assert!(enabled) + } + + #[test] + fn test_is_disabled_by_default() { + let config = ActionConfig::try_from( + r#" +please: + "#, + ) + .unwrap(); + + let enabled = RustAction::new().enabled(&config).unwrap(); + + assert!(!enabled) + } + + #[test] + fn test_is_disabled() { + let config = ActionConfig::try_from( + r#" +please: + actions: + rust: false + "#, + ) + .unwrap(); + + let enabled = RustAction::new().enabled(&config).unwrap(); + + assert!(!enabled) + } + + #[test] + fn test_missing_value_is_enabled() { + let config = ActionConfig::try_from( + r#" +please: + actions: + rust: + "#, + ) + .unwrap(); + + let enabled = RustAction::new().enabled(&config).unwrap(); + + assert!(enabled) + } + + #[test] + fn test_can_edit_empty_file() { + let output = RustAction::default() + .execute_content( + &semver::Version { + major: 0, + minor: 1, + patch: 0, + pre: Prerelease::default(), + build: BuildMetadata::default(), + }, + "", + ) + .unwrap(); + + pretty_assertions::assert_eq!( + r#"[workspace.package] +version = "0.1.0" +"#, + &output, + ) + } + + #[test] + fn test_only_edits_stuff() { + let input = r#" +[workspace] +members = ["."] + + +[package] +something = {some = "something"} + +# Some comment + +[workspace.package] +version = "0.0.0" # some comment +readme = "../../" +"#; + + let output = RustAction::default() + .execute_content( + &semver::Version { + major: 0, + minor: 1, + patch: 0, + pre: Prerelease::default(), + build: BuildMetadata::default(), + }, + input, + ) + .unwrap(); + + pretty_assertions::assert_eq!( + r#" +[workspace] +members = ["."] + + +[package] +something = {some = "something"} + +# Some comment + +[workspace.package] +version = "0.1.0" +readme = "../../" +"#, + &output, + ) + } +} diff --git a/crates/cuddle-please-commands/Cargo.toml b/crates/cuddle-please-commands/Cargo.toml index f4cc28c..db2f2a4 100644 --- a/crates/cuddle-please-commands/Cargo.toml +++ b/crates/cuddle-please-commands/Cargo.toml @@ -12,6 +12,7 @@ publishable = true [dependencies] cuddle-please-frontend.workspace = true cuddle-please-misc.workspace = true +cuddle-please-actions.workspace = true anyhow.workspace = true tracing.workspace = true diff --git a/crates/cuddle-please-commands/src/command.rs b/crates/cuddle-please-commands/src/command.rs index 5913f58..2507f90 100644 --- a/crates/cuddle-please-commands/src/command.rs +++ b/crates/cuddle-please-commands/src/command.rs @@ -6,6 +6,7 @@ use std::{ }; use clap::{Parser, Subcommand}; +use cuddle_please_actions::Actions; use cuddle_please_frontend::{gatheres::ConfigArgs, PleaseConfig, PleaseConfigBuilder}; use cuddle_please_misc::{ ConsoleUi, DynRemoteGitClient, DynUi, GiteaClient, GlobalArgs, LocalGitClient, StdinFn, @@ -92,16 +93,16 @@ impl Command { pub fn execute(self, current_dir: Option<&Path>) -> anyhow::Result<()> { match &self.commands { Some(Commands::Release {}) => { - let (config, git_client, gitea_client) = self.get_deps(current_dir)?; - ReleaseCommandHandler::new(self.ui, config, git_client, gitea_client) + let (config, git_client, gitea_client, actions) = self.get_deps(current_dir)?; + ReleaseCommandHandler::new(self.ui, config, git_client, gitea_client, actions) .execute(self.global.dry_run)?; } Some(Commands::Config { command }) => { - let (config, _, _) = self.get_deps(current_dir)?; + let (config, _, _, _) = self.get_deps(current_dir)?; ConfigCommandHandler::new(self.ui, config).execute(command)?; } Some(Commands::Gitea { command }) => { - let (config, _, gitea_client) = self.get_deps(current_dir)?; + let (config, _, gitea_client, _) = self.get_deps(current_dir)?; GiteaCommandHandler::new(self.ui, config, gitea_client) .execute(command, self.global.token.expect("token to be set").deref())?; @@ -118,7 +119,7 @@ impl Command { fn get_deps( &self, current_dir: Option<&Path>, - ) -> anyhow::Result<(PleaseConfig, VcsClient, DynRemoteGitClient)> { + ) -> anyhow::Result<(PleaseConfig, VcsClient, DynRemoteGitClient, Actions)> { let config = self.build_config(current_dir)?; let git_client = self.get_git(&config, self.global.token.clone().expect("token to be set"))?; @@ -140,7 +141,9 @@ impl Command { tracing_subscriber::fmt().with_env_filter(env_filter).init(); } - Ok((config, git_client, gitea_client)) + let actions = self.get_actions()?; + + Ok((config, git_client, gitea_client, actions)) } fn build_config(&self, current_dir: Option<&Path>) -> Result { @@ -183,6 +186,10 @@ impl Command { )), } } + + fn get_actions(&self) -> anyhow::Result { + Actions::from_cuddle() + } } #[derive(Debug, Clone, Subcommand)] diff --git a/crates/cuddle-please-commands/src/release_command.rs b/crates/cuddle-please-commands/src/release_command.rs index af58090..83ec577 100644 --- a/crates/cuddle-please-commands/src/release_command.rs +++ b/crates/cuddle-please-commands/src/release_command.rs @@ -1,3 +1,4 @@ +use cuddle_please_actions::Actions; use cuddle_please_frontend::PleaseConfig; use ::semver::Version; @@ -13,6 +14,7 @@ pub struct ReleaseCommandHandler { config: PleaseConfig, git_client: VcsClient, gitea_client: DynRemoteGitClient, + actions: Actions, } impl ReleaseCommandHandler { @@ -21,12 +23,14 @@ impl ReleaseCommandHandler { config: PleaseConfig, git_client: VcsClient, gitea_client: DynRemoteGitClient, + actions: Actions, ) -> Self { Self { ui, config, git_client, gitea_client, + actions, } } @@ -69,6 +73,8 @@ impl ReleaseCommandHandler { let (changelog_placement, changelog, changelog_last_changes) = compose_changelog(&commit_strs, &next_version, source)?; + self.actions.execute(&next_version)?; + if let Some(first_commit) = commit_strs.first() { if first_commit.contains("chore(release): ") { tracing::trace!("creating release");