From de013fcb3f1a49e0d45dec8ba834558fbdc7da65 Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Sat, 18 Oct 2025 15:21:53 +0700 Subject: [PATCH 01/13] feat: implement handlebars step 1-2 --- Cargo.lock | 254 +++++++++++++- Cargo.toml | 3 +- src/cli/utils/mod.rs | 6 + src/cli/utils/template/context.rs | 258 +++++++++++++++ src/cli/utils/template/helpers.rs | 531 ++++++++++++++++++++++++++++++ src/cli/utils/template/mod.rs | 9 + src/cli/utils/template/types.rs | 99 ++++++ src/error.rs | 4 + 8 files changed, 1161 insertions(+), 3 deletions(-) create mode 100644 src/cli/utils/template/context.rs create mode 100644 src/cli/utils/template/helpers.rs create mode 100644 src/cli/utils/template/mod.rs create mode 100644 src/cli/utils/template/types.rs diff --git a/Cargo.lock b/Cargo.lock index abf59ac..119cbc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,15 @@ dependencies = [ "serde", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -178,6 +187,101 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -206,6 +310,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "futures" version = "0.3.31" @@ -301,6 +411,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -319,6 +439,22 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "handlebars" +version = "6.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "hashbrown" version = "0.16.0" @@ -355,11 +491,17 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" -version = "2.11.4" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", "hashbrown", @@ -422,6 +564,21 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -466,6 +623,49 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pest" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -726,6 +926,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" @@ -795,6 +1006,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml_datetime" version = "0.7.3" @@ -825,6 +1056,18 @@ dependencies = [ "winnow", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -837,6 +1080,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -1068,6 +1317,7 @@ version = "0.0.0" dependencies = [ "chrono", "clap", + "handlebars", "indexmap", "libc", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index db5e2bf..e9a6963 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,8 @@ path = "src/main.rs" [dependencies] chrono = "^0.4.41" clap = { version = "^4.4", features = ["derive"] } -indexmap = { version = "^2.0", features = ["serde"] } +handlebars = "^6.3" +indexmap = { version = "^2.12", features = ["serde"] } libc = "^0.2" once_cell = "^1.19" regex = "^1.11" diff --git a/src/cli/utils/mod.rs b/src/cli/utils/mod.rs index 4b0eb4c..259131d 100644 --- a/src/cli/utils/mod.rs +++ b/src/cli/utils/mod.rs @@ -1,5 +1,11 @@ pub mod format_handler; pub mod output_formatter; +pub mod template; pub use format_handler::InputFormatHandler; pub use output_formatter::OutputFormatter; +pub use template::{ + PreReleaseContext, + Template, + TemplateContext, +}; diff --git a/src/cli/utils/template/context.rs b/src/cli/utils/template/context.rs new file mode 100644 index 0000000..86658e9 --- /dev/null +++ b/src/cli/utils/template/context.rs @@ -0,0 +1,258 @@ +use crate::version::Zerv; +use crate::version::pep440::PEP440; +use crate::version::semver::SemVer; + +/// Template context for Handlebars rendering +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct TemplateContext { + // Core version fields + pub major: Option, + pub minor: Option, + pub patch: Option, + pub epoch: Option, + + // Metadata fields + pub post: Option, + pub dev: Option, + + // Pre-release fields + pub pre_release: Option, + + // VCS fields + pub distance: Option, + pub dirty: Option, + pub bumped_branch: Option, + pub bumped_commit_hash: Option, + pub bumped_commit_hash_short: Option, + pub bumped_timestamp: Option, + + // Last version fields + pub last_branch: Option, + pub last_commit_hash: Option, + pub last_commit_hash_short: Option, + pub last_timestamp: Option, + + // Custom variables + pub custom: serde_json::Value, + + // Formatted versions + pub pep440: String, + pub semver: String, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +pub struct PreReleaseContext { + pub label: String, + pub number: Option, +} + +impl TemplateContext { + pub fn from_zerv(zerv: &Zerv) -> Self { + let vars = &zerv.vars; + Self { + major: vars.major, + minor: vars.minor, + patch: vars.patch, + epoch: vars.epoch, + post: vars.post, + dev: vars.dev, + pre_release: vars.pre_release.as_ref().map(|pr| PreReleaseContext { + label: pr.label.label_str().to_string(), + number: pr.number, + }), + distance: vars.distance, + dirty: vars.dirty, + bumped_branch: vars.bumped_branch.clone(), + bumped_commit_hash: vars.bumped_commit_hash.clone(), + bumped_commit_hash_short: vars.get_bumped_commit_hash_short(), + bumped_timestamp: vars.bumped_timestamp, + last_branch: vars.last_branch.clone(), + last_commit_hash: vars.last_commit_hash.clone(), + last_commit_hash_short: vars.get_last_commit_hash_short(), + last_timestamp: vars.last_timestamp, + custom: vars.custom.clone(), + pep440: PEP440::from(zerv.clone()).to_string(), + semver: SemVer::from(zerv.clone()).to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + use crate::test_utils::zerv::ZervFixture; + use crate::version::zerv::PreReleaseLabel; + + fn basic_zerv() -> ZervFixture { + ZervFixture::new() + } + + fn vcs_zerv() -> ZervFixture { + ZervFixture::new().with_version(1, 2, 3).with_vcs_data( + 0, + true, + "main".to_string(), + "abcdef123456".to_string(), + "xyz789".to_string(), + 1234567890, + "main".to_string(), + ) + } + + fn pre_release_zerv() -> ZervFixture { + ZervFixture::new().with_pre_release(PreReleaseLabel::Alpha, Some(1)) + } + + fn custom_vars_zerv() -> ZervFixture { + use crate::version::zerv::{ + Zerv, + ZervSchema, + ZervVars, + }; + + let vars = ZervVars { + major: Some(2), + minor: Some(1), + patch: Some(0), + custom: serde_json::json!({ + "build_id": 123, + "env": "prod", + "metadata": { + "author": "ci", + "timestamp": 1703123456 + } + }), + ..Default::default() + }; + + let schema = ZervSchema::semver_default().unwrap(); + let zerv = Zerv::new(schema, vars).unwrap(); + ZervFixture::from(zerv) + } + + fn basic_context() -> TemplateContext { + TemplateContext { + major: Some(1), + minor: Some(0), + patch: Some(0), + epoch: None, + post: None, + dev: None, + pre_release: None, + distance: None, + dirty: None, + bumped_branch: None, + bumped_commit_hash: None, + bumped_commit_hash_short: None, + bumped_timestamp: None, + last_branch: None, + last_commit_hash: None, + last_commit_hash_short: None, + last_timestamp: None, + custom: serde_json::Value::Null, + pep440: "1.0.0".to_string(), + semver: "1.0.0".to_string(), + } + } + + fn vcs_context() -> TemplateContext { + TemplateContext { + major: Some(1), + minor: Some(2), + patch: Some(3), + epoch: None, + post: None, + dev: None, + pre_release: None, + distance: Some(0), + dirty: Some(true), + bumped_branch: Some("main".to_string()), + bumped_commit_hash: Some("abcdef123456".to_string()), + bumped_commit_hash_short: Some("abcdef1".to_string()), + bumped_timestamp: None, + last_branch: Some("main".to_string()), + last_commit_hash: Some("xyz789".to_string()), + last_commit_hash_short: Some("xyz789".to_string()), + last_timestamp: Some(1234567890), + custom: serde_json::Value::Null, + pep440: "1.2.3".to_string(), + semver: "1.2.3".to_string(), + } + } + + fn pre_release_context() -> TemplateContext { + TemplateContext { + major: Some(1), + minor: Some(0), + patch: Some(0), + epoch: None, + post: None, + dev: None, + pre_release: Some(PreReleaseContext { + label: "alpha".to_string(), + number: Some(1), + }), + distance: None, + dirty: None, + bumped_branch: None, + bumped_commit_hash: None, + bumped_commit_hash_short: None, + bumped_timestamp: None, + last_branch: None, + last_commit_hash: None, + last_commit_hash_short: None, + last_timestamp: None, + custom: serde_json::Value::Null, + pep440: "1.0.0a1".to_string(), + semver: "1.0.0-alpha.1".to_string(), + } + } + + fn custom_vars_context() -> TemplateContext { + TemplateContext { + major: Some(2), + minor: Some(1), + patch: Some(0), + epoch: None, + post: None, + dev: None, + pre_release: None, + distance: None, + dirty: None, + bumped_branch: None, + bumped_commit_hash: None, + bumped_commit_hash_short: None, + bumped_timestamp: None, + last_branch: None, + last_commit_hash: None, + last_commit_hash_short: None, + last_timestamp: None, + custom: serde_json::json!({ + "build_id": 123, + "env": "prod", + "metadata": { + "author": "ci", + "timestamp": 1703123456 + } + }), + pep440: "2.1.0".to_string(), + semver: "2.1.0".to_string(), + } + } + + #[rstest] + #[case::basic(basic_zerv(), basic_context())] + #[case::with_vcs(vcs_zerv(), vcs_context())] + #[case::with_pre_release(pre_release_zerv(), pre_release_context())] + #[case::with_custom_vars(custom_vars_zerv(), custom_vars_context())] + fn test_template_context_from_zerv( + #[case] fixture: ZervFixture, + #[case] expected: TemplateContext, + ) { + let zerv = fixture.build(); + let context = TemplateContext::from_zerv(&zerv); + assert_eq!(context, expected); + } +} diff --git a/src/cli/utils/template/helpers.rs b/src/cli/utils/template/helpers.rs new file mode 100644 index 0000000..b3cb539 --- /dev/null +++ b/src/cli/utils/template/helpers.rs @@ -0,0 +1,531 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{ + Hash, + Hasher, +}; + +use handlebars::{ + Context, + Handlebars, + Helper, + HelperResult, + Output, + RenderContext, + RenderErrorReason, +}; + +use crate::error::ZervError; +use crate::utils::constants::timestamp_patterns; +use crate::utils::sanitize::Sanitizer; + +/// Register custom Zerv helpers for Handlebars +pub fn register_helpers(handlebars: &mut Handlebars) -> Result<(), ZervError> { + handlebars.register_helper("sanitize", Box::new(sanitize_helper)); + handlebars.register_helper("hash", Box::new(hash_helper)); + handlebars.register_helper("hash_int", Box::new(hash_int_helper)); + handlebars.register_helper("prefix", Box::new(prefix_helper)); + handlebars.register_helper("format_timestamp", Box::new(format_timestamp_helper)); + handlebars.register_helper("add", Box::new(add_helper)); + handlebars.register_helper("subtract", Box::new(subtract_helper)); + handlebars.register_helper("multiply", Box::new(multiply_helper)); + Ok(()) +} + +fn sanitize_helper( + h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let value = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other( + "sanitize helper requires a string parameter".to_string(), + )) + })?; + + // Check for preset format + let format = h.hash_get("preset").and_then(|v| v.value().as_str()); + + // Check for custom parameters + let separator = h.hash_get("separator").and_then(|v| v.value().as_str()); + let keep_zeros = h.hash_get("keep_zeros").and_then(|v| v.value().as_bool()); + let max_length = h.hash_get("max_length").and_then(|v| v.value().as_u64()); + let lowercase = h.hash_get("lowercase").and_then(|v| v.value().as_bool()); + + let has_custom_params = + separator.is_some() || keep_zeros.is_some() || max_length.is_some() || lowercase.is_some(); + + // Error if both format and custom parameters are specified + if format.is_some() && has_custom_params { + return Err(handlebars::RenderError::from(RenderErrorReason::Other( + "Cannot use preset format with custom parameters".to_string(), + ))); + } + + let sanitized = if let Some(fmt) = format { + // Use preset format + match fmt { + "semver_str" | "semver" | "dotted" => Sanitizer::semver_str().sanitize(value), + "pep440_local_str" | "pep440" | "lower_dotted" => { + Sanitizer::pep440_local_str().sanitize(value) + } + "uint" => Sanitizer::uint().sanitize(value), + _ => { + return Err(handlebars::RenderError::from(RenderErrorReason::Other( + format!("Unknown sanitize preset: {fmt}"), + ))); + } + } + } else if has_custom_params { + // Use custom parameters + let sanitizer = Sanitizer::str( + separator, + lowercase.unwrap_or(false), + keep_zeros.unwrap_or(false), + max_length.map(|l| l as usize), + ); + sanitizer.sanitize(value) + } else { + // Default to pep440_local_str + Sanitizer::pep440_local_str().sanitize(value) + }; + + out.write(&sanitized)?; + Ok(()) +} + +/// Generate hex hash from input (default: 7 chars) +fn hash_helper( + h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let input = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other( + "hash helper requires a string parameter".to_string(), + )) + })?; + + let length = h.param(1).and_then(|v| v.value().as_u64()).unwrap_or(7) as usize; + + let mut hasher = DefaultHasher::new(); + input.hash(&mut hasher); + let hash = format!("{:x}", hasher.finish()); + + let short = if hash.len() > length { + &hash[..length] + } else { + &hash + }; + + out.write(short)?; + Ok(()) +} + +fn format_with_leading_zeros(hash_num: u64, length: usize) -> String { + let hash_str = hash_num.to_string(); + if hash_str.len() > length { + hash_str[..length].to_string() + } else if length >= 20 { + format!("{hash_num:0length$}") + } else { + format!( + "{:0width$}", + hash_num % 10_u64.pow(length as u32), + width = length + ) + } +} + +fn format_without_leading_zeros(hash_num: u64, length: usize) -> String { + if length == 0 { + return "0".to_string(); + } + + if length == 20 { + let hash_str = hash_num.to_string(); + if hash_str.len() >= 20 { + return hash_str[..20].to_string(); + } + let padded = format!("{hash_num:020}"); + if let Some(stripped) = padded.strip_prefix('0') { + format!("1{stripped}") + } else { + padded + } + } else { + let min_val = 10_u64.pow((length - 1) as u32); + let max_val = 10_u64.pow(length as u32) - 1; + let range = max_val - min_val + 1; + (hash_num % range + min_val).to_string() + } +} + +/// Generate integer hash from input +fn hash_int_helper( + h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let input = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other( + "hash_int helper requires a string parameter".to_string(), + )) + })?; + + let length = h.param(1).and_then(|v| v.value().as_u64()).unwrap_or(7) as usize; + let allow_leading_zero = h + .hash_get("allow_leading_zero") + .and_then(|v| v.value().as_bool()) + .unwrap_or(false); + + // Validate length limits to prevent overflow + if length > 20 { + return Err(handlebars::RenderError::from(RenderErrorReason::Other( + "hash_int length must be 20 or less".to_string(), + ))); + } + + let mut hasher = DefaultHasher::new(); + input.hash(&mut hasher); + let hash_num = hasher.finish(); + + let result = if allow_leading_zero { + format_with_leading_zeros(hash_num, length) + } else { + format_without_leading_zeros(hash_num, length) + }; + + out.write(&result)?; + Ok(()) +} + +/// Get prefix of string to length +fn prefix_helper( + h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let string = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other( + "prefix helper requires a string parameter".to_string(), + )) + })?; + + let length = h + .param(1) + .and_then(|v| v.value().as_u64()) + .unwrap_or(string.len() as u64) as usize; + + let prefix = if string.len() > length { + &string[..length] + } else { + string + }; + + out.write(prefix)?; + Ok(()) +} + +/// Format unix timestamp to string +fn format_timestamp_helper( + h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let timestamp = h.param(0).and_then(|v| v.value().as_u64()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other( + "format_timestamp helper requires a timestamp parameter".to_string(), + )) + })?; + + let format = h + .hash_get("format") + .and_then(|v| v.value().as_str()) + .unwrap_or("%Y-%m-%d"); + + let chrono_format = match format { + timestamp_patterns::COMPACT_DATE => "%Y%m%d", + timestamp_patterns::COMPACT_DATETIME => "%Y%m%d%H%M%S", + _ => format, + }; + + let dt = chrono::DateTime::from_timestamp(timestamp as i64, 0) + .ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other("Invalid timestamp".to_string())) + })? + .naive_utc(); + + let formatted = dt.format(chrono_format).to_string(); + out.write(&formatted)?; + Ok(()) +} + +/// Add two numbers +fn add_helper( + h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let a = h.param(0).and_then(|v| v.value().as_i64()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other( + "add helper requires two numeric parameters".to_string(), + )) + })?; + + let b = h.param(1).and_then(|v| v.value().as_i64()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other( + "add helper requires two numeric parameters".to_string(), + )) + })?; + + out.write(&(a + b).to_string())?; + Ok(()) +} + +/// Subtract two numbers +fn subtract_helper( + h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let a = h.param(0).and_then(|v| v.value().as_i64()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other( + "subtract helper requires two numeric parameters".to_string(), + )) + })?; + + let b = h.param(1).and_then(|v| v.value().as_i64()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other( + "subtract helper requires two numeric parameters".to_string(), + )) + })?; + + out.write(&(a - b).to_string())?; + Ok(()) +} + +/// Multiply two numbers +fn multiply_helper( + h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output, +) -> HelperResult { + let a = h.param(0).and_then(|v| v.value().as_i64()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other( + "multiply helper requires two numeric parameters".to_string(), + )) + })?; + + let b = h.param(1).and_then(|v| v.value().as_i64()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other( + "multiply helper requires two numeric parameters".to_string(), + )) + })?; + + out.write(&(a * b).to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use handlebars::Handlebars; + use rstest::rstest; + + use super::*; + + fn render_template(template: &str) -> String { + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + hb.render_template(template, &()).unwrap() + } + + #[rstest] + // Preset formats + #[case("{{sanitize 'Feature/API-v2' preset='dotted'}}", "Feature.API.v2")] + #[case("{{sanitize 'Build-ID-0051' preset='semver'}}", "Build.ID.51")] + #[case( + "{{sanitize 'Feature/API-v2' preset='lower_dotted'}}", + "feature.api.v2" + )] + #[case("{{sanitize 'Build-ID-0051' preset='pep440'}}", "build.id.51")] + #[case("{{sanitize '0051' preset='uint'}}", "51")] + #[case("{{sanitize 'abc123' preset='uint'}}", "")] + #[case("{{sanitize 'Feature/API-v2'}}", "feature.api.v2")] + // Custom parameters + #[case("{{sanitize 'Feature-API' separator='_'}}", "Feature_API")] + #[case( + "{{sanitize 'Feature-API' separator='_' lowercase=true}}", + "feature_api" + )] + #[case( + "{{sanitize 'test-0051-build' separator='.' keep_zeros=true lowercase=false}}", + "test.0051.build" + )] + #[case( + "{{sanitize 'test-0051-build' separator='.' keep_zeros=false lowercase=false}}", + "test.51.build" + )] + #[case( + "{{sanitize 'VeryLongBranchName' max_length=10 lowercase=false}}", + "VeryLongBr" + )] + #[case( + "{{sanitize 'Test-Branch' separator='-' lowercase=false}}", + "Test-Branch" + )] + #[case( + "{{sanitize 'feature/test' separator='' lowercase=true}}", + "featuretest" + )] + fn test_sanitize_helper(#[case] template: &str, #[case] expected: &str) { + assert_eq!(render_template(template), expected); + } + + #[test] + fn test_sanitize_helper_errors() { + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + // Test conflict between preset and custom parameters + let result = hb.render_template("{{sanitize 'test' preset='dotted' separator='_'}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Cannot use preset format with custom parameters") + ); + + // Test unknown preset + let result = hb.render_template("{{sanitize 'test' preset='unknown_preset'}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Unknown sanitize preset: unknown_preset") + ); + } + + #[rstest] + #[case("{{hash 'test'}}", "c7dedb4")] + #[case("{{hash 'test' 10}}", "c7dedb4632")] + #[case("{{hash_int 'test'}}", "7126668")] + #[case("{{hash_int 'test' 5 allow_leading_zero=false}}", "16668")] + #[case("{{hash_int 'test' 5 allow_leading_zero=true}}", "14402")] + #[case("{{prefix 'abcdef123456789' 7}}", "abcdef1")] + #[case("{{prefix 'abc' 10}}", "abc")] + #[case("{{format_timestamp 1703123456 format='%Y-%m-%d'}}", "2023-12-21")] + #[case("{{format_timestamp 1703123456 format='compact_date'}}", "20231221")] + #[case( + "{{format_timestamp 1703123456 format='compact_datetime'}}", + "20231221015056" + )] + #[case("{{add 5 3}}", "8")] + #[case("{{subtract 10 4}}", "6")] + #[case("{{multiply 7 6}}", "42")] + fn test_helpers(#[case] template: &str, #[case] expected: &str) { + assert_eq!(render_template(template), expected); + } + + fn assert_hash_int_no_leading_zero(input: &str, length: usize) { + let result = render_template(&format!( + "{{{{hash_int '{input}' {length} allow_leading_zero=false}}}}" + )); + assert_eq!(result.len(), length); + if length > 1 { + assert!(!result.starts_with('0')); + } + let num: u64 = result.parse().unwrap(); + let min_val = 10_u64.pow((length - 1) as u32); + let max_val = 10_u64.pow(length as u32) - 1; + assert!(num >= min_val && num <= max_val); + } + + fn assert_hash_int_with_leading_zero(input: &str, length: usize) { + let result = render_template(&format!( + "{{{{hash_int '{input}' {length} allow_leading_zero=true}}}}" + )); + assert!(result.len() <= length); + } + + #[test] + fn test_hash_int_length_limits() { + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + // Test length > 20 should error for both cases + let result = hb.render_template("{{hash_int 'test' 21 allow_leading_zero=false}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("length must be 20 or less") + ); + + let result = hb.render_template("{{hash_int 'test' 21 allow_leading_zero=true}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("length must be 20 or less") + ); + + // Test length = 20 should work for both cases + let result = hb.render_template("{{hash_int 'test' 20 allow_leading_zero=false}}", &()); + assert!(result.is_ok()); + assert_eq!(result.unwrap().len(), 20); + + let result = hb.render_template("{{hash_int 'test' 20 allow_leading_zero=true}}", &()); + assert!(result.is_ok()); + assert!(result.unwrap().len() <= 20); + } + + #[test] + fn test_hash_int_digit_guarantees() { + let chars: Vec = ('0'..='9').chain('a'..='z').collect(); + let inputs: Vec = chars + .iter() + .flat_map(|&a| chars.iter().map(move |&b| format!("{a}{b}"))) + .collect(); + + for input in inputs { + for length in 1..=5 { + assert_hash_int_no_leading_zero(&input, length); + assert_hash_int_with_leading_zero(&input, length); + } + } + } + + #[rstest] + #[case("{{add 5 3}}", "8")] + #[case("{{add 0 0}}", "0")] + #[case("{{add -5 3}}", "-2")] + #[case("{{subtract 10 4}}", "6")] + #[case("{{subtract 0 5}}", "-5")] + #[case("{{subtract -3 -7}}", "4")] + #[case("{{multiply 7 6}}", "42")] + #[case("{{multiply 0 100}}", "0")] + #[case("{{multiply -4 3}}", "-12")] + fn test_math_helpers(#[case] template: &str, #[case] expected: &str) { + assert_eq!(render_template(template), expected); + } +} diff --git a/src/cli/utils/template/mod.rs b/src/cli/utils/template/mod.rs new file mode 100644 index 0000000..d39b01b --- /dev/null +++ b/src/cli/utils/template/mod.rs @@ -0,0 +1,9 @@ +mod context; +mod helpers; +mod types; + +pub use context::{ + PreReleaseContext, + TemplateContext, +}; +pub use types::Template; diff --git a/src/cli/utils/template/types.rs b/src/cli/utils/template/types.rs new file mode 100644 index 0000000..daf5ba9 --- /dev/null +++ b/src/cli/utils/template/types.rs @@ -0,0 +1,99 @@ +use std::fmt::Display; +use std::str::FromStr; + +use super::context::TemplateContext; +use super::helpers::register_helpers; +use crate::error::ZervError; +use crate::version::Zerv; + +/// Template-aware type that can hold a direct value or template string +#[derive(Debug, Clone, PartialEq)] +pub enum Template { + Value(T), + Template(String), // Handlebars template string +} + +impl Template +where + T: FromStr + Clone, + T::Err: Display, +{ + /// Resolve template using Zerv object context, return final value + pub fn resolve(&self, zerv: &Zerv) -> Result { + match self { + Template::Value(v) => Ok(v.clone()), + Template::Template(template) => { + let rendered = Self::render_template(template, zerv)?; + let parsed = rendered.parse::().map_err(|e| { + ZervError::TemplateError(format!("Failed to parse '{rendered}': {e}")) + })?; + Ok(parsed) + } + } + } + + /// Render Handlebars template using Zerv object as context + fn render_template(template: &str, zerv: &Zerv) -> Result { + let mut handlebars = handlebars::Handlebars::new(); + handlebars.set_strict_mode(false); // Allow missing variables + + // Register custom Zerv helpers + register_helpers(&mut handlebars)?; + + // Create template context from Zerv object + let template_context = TemplateContext::from_zerv(zerv); + let context = serde_json::to_value(template_context) + .map_err(|e| ZervError::TemplateError(format!("Serialization error: {e}")))?; + + handlebars + .render_template(template, &context) + .map_err(|e| ZervError::TemplateError(format!("Template render error: {e}"))) + } +} + +impl FromStr for Template +where + T: FromStr, + T::Err: Display, +{ + type Err = String; + + fn from_str(input: &str) -> Result { + if input.contains("{{") && input.contains("}}") { + Ok(Template::Template(input.to_string())) + } else { + match input.parse::() { + Ok(value) => Ok(Template::Value(value)), + Err(_) => Ok(Template::Template(input.to_string())), // Fallback to template + } + } + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use rstest::rstest; + + use super::*; + use crate::test_utils::zerv::ZervFixture; + + #[rstest] + #[case("{{template}}", Template::Template("{{template}}".to_string()))] + #[case("no_braces", Template::Value("no_braces".to_string()))] + fn test_template_from_str(#[case] input: &str, #[case] expected: Template) { + let result = Template::from_str(input).unwrap(); + assert_eq!(result, expected); + } + + #[rstest] + #[case("test", "test")] + #[case("{{major}}.{{minor}}", "1.2")] + fn test_template_resolve(#[case] input: &str, #[case] expected: &str) { + let template: Template = Template::from_str(input).unwrap(); + let zerv = ZervFixture::new().with_version(1, 2, 0).build(); + let result = template.resolve(&zerv).unwrap(); + assert_eq!(result, expected); + } +} diff --git a/src/error.rs b/src/error.rs index d51d389..50c20e7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -44,6 +44,8 @@ pub enum ZervError { InvalidBumpTarget(String), /// Feature not yet implemented NotImplemented(String), + /// Template processing error + TemplateError(String), // System errors /// IO error @@ -79,6 +81,7 @@ impl std::fmt::Display for ZervError { ZervError::InvalidArgument(msg) => write!(f, "Invalid argument: {msg}"), ZervError::InvalidBumpTarget(msg) => write!(f, "Invalid bump target: {msg}"), ZervError::NotImplemented(msg) => write!(f, "Not implemented: {msg}"), + ZervError::TemplateError(msg) => write!(f, "Template error: {msg}"), // System errors ZervError::Io(err) => write!(f, "IO error: {err}"), @@ -133,6 +136,7 @@ impl PartialEq for ZervError { (ZervError::InvalidArgument(a), ZervError::InvalidArgument(b)) => a == b, (ZervError::InvalidBumpTarget(a), ZervError::InvalidBumpTarget(b)) => a == b, (ZervError::NotImplemented(a), ZervError::NotImplemented(b)) => a == b, + (ZervError::TemplateError(a), ZervError::TemplateError(b)) => a == b, _ => false, } } From d0f4cbe654e2619b7d85136c16d3e480ebbc3038 Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Sat, 18 Oct 2025 16:12:53 +0700 Subject: [PATCH 02/13] feat: add more test for schema bump args --- ...handlebars-cli-integration-updated-plan.md | 1341 +++-------------- src/test_utils/types.rs | 4 +- src/test_utils/version_args.rs | 7 +- src/version/zerv/bump/mod.rs | 57 + 4 files changed, 255 insertions(+), 1154 deletions(-) diff --git a/.dev/25-handlebars-cli-integration-updated-plan.md b/.dev/25-handlebars-cli-integration-updated-plan.md index ecca041..36a3646 100644 --- a/.dev/25-handlebars-cli-integration-updated-plan.md +++ b/.dev/25-handlebars-cli-integration-updated-plan.md @@ -14,567 +14,85 @@ Implement Handlebars templating support for CLI arguments based on the completed ## Current State Analysis -### ✅ What's Already Implemented - -1. **String Sanitization** (`src/utils/sanitize.rs`): - - `Sanitizer` struct with format-specific methods - - `pep440_local_str()`, `semver_str()`, `uint()`, `key()` sanitizers - - Comprehensive sanitization rules and testing - -2. **Component Resolution** (`src/version/zerv/components.rs`): - - `Var` enum with categorization (primary/secondary/context components) - - `resolve_value()` and `resolve_expanded_values()` methods - - Integration with sanitizers for format-specific cleaning - - Support for custom fields via `Var::Custom(String)` - -3. **CLI Structure** (`src/cli/version/args/`): - - `MainConfig` with `output_template: Option` - - `OverridesConfig` with all override fields as `Option` or `Option` - - `BumpsConfig` with all bump fields as `Option>` - - Schema component overrides: `core`, `extra_core`, `build` as `Vec` - -4. **Schema System** (`src/version/zerv/schema/`): - - Validated schema API with private fields and getters - - Component placement validation (primary→core, secondary→extra_core) - - Schema-first conversion system - -### 🔄 What Needs Implementation - -1. **Template Types**: Replace primitive types with template-aware types -2. **Handlebars Integration**: Add handlebars dependency and processing -3. **Template Context**: Create template context from ZervVars -4. **Custom Helpers**: Implement Zerv-specific Handlebars helpers -5. **CLI Integration**: Update argument types to support templating -6. **Render Timing**: Implement early vs late rendering logic +### ✅ What's Already Implemented (Steps 1-2) -## Implementation Plan - -### Step 1: Add Handlebars Dependency - -**File**: `Cargo.toml` - -```toml -[dependencies] -# ... existing dependencies ... -handlebars = "^6.3" -``` - -### Step 2: Template Module Implementation - -**File**: `src/cli/utils/template/mod.rs` (new) - -```rust -mod types; -mod context; -mod helpers; -pub use types::{Template, IndexValue}; -pub use context::{TemplateContext, PreReleaseContext}; -``` - -**File**: `src/cli/utils/template/types.rs` (new) - -```rust -use std::str::FromStr; -use std::fmt::Display; -use crate::version::zerv::vars::ZervVars; -use crate::error::ZervError; -use super::context::TemplateContext; -use super::helpers::register_helpers; - -/// Template-aware type that can hold a direct value or template string -#[derive(Debug, Clone, PartialEq)] -pub enum Template { - Value(T), - Template(String), // Handlebars template string -} - -impl Template -where - T: FromStr + Clone, - T::Err: Display, -{ - /// Resolve template using ZervVars context, return final value - pub fn resolve(&self, vars: &ZervVars) -> Result { - match self { - Template::Value(v) => Ok(v.clone()), - Template::Template(template) => { - let rendered = Self::render_template(template, vars)?; - let parsed = rendered.parse::() - .map_err(|e| ZervError::TemplateError(format!("Failed to parse '{}': {}", rendered, e)))?; - Ok(parsed) - } - } - } - - /// Render Handlebars template using ZervVars as context - fn render_template(template: &str, vars: &ZervVars) -> Result { - let mut handlebars = Handlebars::new(); - handlebars.set_strict_mode(false); // Allow missing variables - - // Register custom Zerv helpers - Self::register_custom_helpers(&mut handlebars)?; - - // Create template context from ZervVars - let template_context = TemplateContext::from_zerv_vars(vars); - let context = serde_json::to_value(template_context) - .map_err(|e| ZervError::TemplateError(format!("Serialization error: {}", e)))?; - - handlebars.render_template(template, &context) - .map_err(|e| ZervError::TemplateError(format!("Template render error: {}", e))) - } - - /// Render Handlebars template using ZervVars as context - fn render_template(template: &str, vars: &ZervVars) -> Result { - let mut handlebars = handlebars::Handlebars::new(); - handlebars.set_strict_mode(false); // Allow missing variables - - // Register custom Zerv helpers - register_helpers(&mut handlebars)?; - - // Create template context from ZervVars - let template_context = TemplateContext::from_zerv_vars(vars); - let context = serde_json::to_value(template_context) - .map_err(|e| ZervError::TemplateError(format!("Serialization error: {}", e)))?; - - handlebars.render_template(template, &context) - .map_err(|e| ZervError::TemplateError(format!("Template render error: {}", e))) - } -} - -impl FromStr for Template -where - T: FromStr, - T::Err: Display, -{ - type Err = String; - - fn from_str(input: &str) -> Result { - if input.contains("{{") && input.contains("}}") { - Ok(Template::Template(input.to_string())) - } else { - match input.parse::() { - Ok(value) => Ok(Template::Value(value)), - Err(_) => Ok(Template::Template(input.to_string())), // Fallback to template - } - } - } -} - -/// INDEX=VALUE pair for schema component arguments with template support -#[derive(Debug, Clone, PartialEq)] -pub struct IndexValue { - pub index: usize, - pub value: Template, -} - -impl FromStr for IndexValue { - type Err = String; - - fn from_str(s: &str) -> Result { - let (index_str, value_str) = s.split_once('=') - .ok_or_else(|| format!("Invalid INDEX=VALUE format: {}", s))?; - - let index = index_str.parse::() - .map_err(|_| format!("Invalid index: {}", index_str))?; - - let value = Template::from_str(value_str)?; - - Ok(IndexValue { index, value }) - } -} - -/// Template context for Handlebars rendering -#[derive(Debug, Clone, serde::Serialize)] -pub struct TemplateContext { - // Core version fields - pub major: Option, - pub minor: Option, - pub patch: Option, - pub epoch: Option, - - // Metadata fields - pub post: Option, - pub dev: Option, - - // Pre-release fields - pub pre_release: Option, - - // VCS fields - pub distance: Option, - pub dirty: Option, - pub bumped_branch: Option, - pub bumped_commit_hash: Option, - pub bumped_commit_hash_short: Option, - pub bumped_timestamp: Option, - - // Last version fields - pub last_branch: Option, - pub last_commit_hash: Option, - pub last_commit_hash_short: Option, - pub last_timestamp: Option, - - // Custom variables - pub custom: serde_json::Value, - - // Formatted versions - pub pep440: String, - pub semver: String, -} - -#[derive(Debug, Clone, serde::Serialize)] -pub struct PreReleaseContext { - pub label: String, - pub number: Option, -} - -impl TemplateContext { - pub fn from_zerv_vars(vars: &ZervVars) -> Self { - Self { - major: vars.major, - minor: vars.minor, - patch: vars.patch, - epoch: vars.epoch, - post: vars.post, - dev: vars.dev, - pre_release: vars.pre_release.as_ref().map(|pr| PreReleaseContext { - label: pr.label.label_str().to_string(), - number: pr.number, - }), - distance: vars.distance, - dirty: vars.dirty, - bumped_branch: vars.bumped_branch.clone(), - bumped_commit_hash: vars.bumped_commit_hash.clone(), - bumped_commit_hash_short: vars.get_bumped_commit_hash_short(), - bumped_timestamp: vars.bumped_timestamp, - last_branch: vars.last_branch.clone(), - last_commit_hash: vars.last_commit_hash.clone(), - last_commit_hash_short: vars.get_last_commit_hash_short(), - last_timestamp: vars.last_timestamp, - custom: vars.custom.clone(), - // TODO: Generate formatted versions - pep440: "".to_string(), // Will be populated by conversion - semver: "".to_string(), // Will be populated by conversion - } - } -} - -``` +1. **✅ Handlebars Integration**: `handlebars = "^6.3"` added to Cargo.toml +2. **✅ Template Module**: Complete template infrastructure implemented + - `src/cli/utils/template/mod.rs` - Module exports + - `src/cli/utils/template/types.rs` - Template enum with resolve() method + - `src/cli/utils/template/context.rs` - TemplateContext and PreReleaseContext + - `src/cli/utils/template/helpers.rs` - Custom Handlebars helpers + - `src/cli/utils/mod.rs` - Template module exported + - `src/error.rs` - TemplateError variant added -**File**: `src/cli/utils/template/helpers.rs` (new) +### 🔄 What Needs Implementation (Steps 3-4) -```rust -use handlebars::{Handlebars, Helper, Context, RenderContext, Output, HelperResult}; -use crate::error::ZervError; +3. **CLI Integration**: Update argument types to support templating +4. **Pipeline Integration**: Add early/late rendering and update processing logic -/// Register all custom Zerv helpers -pub fn register_helpers(handlebars: &mut Handlebars) -> Result<(), ZervError> { - // Math helpers - handlebars.register_helper("add", Box::new(add_helper)); - handlebars.register_helper("subtract", Box::new(subtract_helper)); - handlebars.register_helper("multiply", Box::new(multiply_helper)); +**Deviations from Plan**: - // String helpers - handlebars.register_helper("hash", Box::new(hash_helper)); - handlebars.register_helper("hash_int", Box::new(hash_int_helper)); - handlebars.register_helper("prefix", Box::new(prefix_helper)); +- `Template::resolve()` takes `&Zerv` instead of `&ZervVars` (cleaner API) +- `TemplateContext::from_zerv()` instead of `from_zerv_vars()` (matches implementation) +- Template module structure is cleaner than planned - // Timestamp helpers - handlebars.register_helper("format_timestamp", Box::new(format_timestamp_helper)); +**Current CLI Structure Analysis**: - Ok(()) -} +- `MainConfig::output_template: Option` - needs Template +- `OverridesConfig` - all version fields are `Option` - need Template +- `OverridesConfig` - schema fields are `Vec` - need Template +- `BumpsConfig` - all bump fields are `Option>` - need Template -// Helper implementations would go here... -// (Math, string, and timestamp helper functions) -``` +## Schema Component Template Design -**File**: `src/cli/utils/template/context.rs` (new) +### Approach: ```rust -use crate::version::zerv::vars::ZervVars; -use serde::Serialize; - -/// Template context for Handlebars rendering -#[derive(Debug, Clone, Serialize)] -pub struct TemplateContext { - // Core version fields - pub major: Option, - pub minor: Option, - pub patch: Option, - pub epoch: Option, - - // Metadata fields - pub post: Option, - pub dev: Option, - - // Pre-release fields - pub pre_release: Option, - - // VCS fields - pub distance: Option, - pub dirty: Option, - pub bumped_branch: Option, - pub bumped_commit_hash: Option, - pub bumped_commit_hash_short: Option, - pub bumped_timestamp: Option, - - // Last version fields - pub last_branch: Option, - pub last_commit_hash: Option, - pub last_commit_hash_short: Option, - pub last_timestamp: Option, - - // Custom variables - pub custom: serde_json::Value, - - // Formatted versions (only available in late rendering) - pub pep440: Option, - pub semver: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct PreReleaseContext { - pub label: String, - pub number: Option, -} - -impl TemplateContext { - pub fn from_zerv_vars(vars: &ZervVars) -> Self { - Self { - major: vars.major, - minor: vars.minor, - patch: vars.patch, - epoch: vars.epoch, - post: vars.post, - dev: vars.dev, - pre_release: vars.pre_release.as_ref().map(|pr| PreReleaseContext { - label: pr.label.label_str().to_string(), - number: pr.number, - }), - distance: vars.distance, - dirty: vars.dirty, - bumped_branch: vars.bumped_branch.clone(), - bumped_commit_hash: vars.bumped_commit_hash.clone(), - bumped_commit_hash_short: vars.get_bumped_commit_hash_short(), - bumped_timestamp: vars.bumped_timestamp, - last_branch: vars.last_branch.clone(), - last_commit_hash: vars.last_commit_hash.clone(), - last_commit_hash_short: vars.get_last_commit_hash_short(), - last_timestamp: vars.last_timestamp, - custom: vars.custom.clone(), - // Formatted versions populated separately based on timing - pep440: None, - semver: None, - } - } -}andlebars = handlebars::Handlebars::new(); - handlebars.set_strict_mode(false); - register_helpers(&mut handlebars)?; - - let context = TemplateContext::from_zerv_vars(vars); - let json_context = serde_json::to_value(context) - .map_err(|e| ZervError::TemplateError(format!("Serialization error: {}", e)))?; - - handlebars.render_template(template, &json_context) - .map_err(|e| ZervError::TemplateError(format!("Template render error: {}", e))) -} +pub core: Vec>, // Each can be: "0=value", "0={{major}}", etc. ``` -**File**: `src/cli/utils/template/context.rs` (new) +### Processing Flow: ```rust -use crate::version::zerv::vars::ZervVars; - -#[derive(Debug, Clone, serde::Serialize)] -pub struct TemplateContext { - pub major: Option, - pub minor: Option, - pub patch: Option, - pub epoch: Option, - pub post: Option, - pub dev: Option, - pub pre_release: Option, - pub distance: Option, - pub dirty: Option, - pub bumped_branch: Option, - pub bumped_commit_hash: Option, - pub bumped_commit_hash_short: Option, - pub bumped_timestamp: Option, - pub last_branch: Option, - pub last_commit_hash: Option, - pub last_commit_hash_short: Option, - pub last_timestamp: Option, - pub custom: serde_json::Value, - pub pep440: String, - pub semver: String, -} +// 1. CLI args as templates +pub core: Vec> -#[derive(Debug, Clone, serde::Serialize)] -pub struct PreReleaseContext { - pub label: String, - pub number: Option, -} +// 2. Resolve templates +let resolved: Vec = core.iter().map(|t| t.resolve(vars)).collect(); -impl TemplateContext { - pub fn from_zerv_vars(vars: &ZervVars) -> Self { - Self { - major: vars.major, - minor: vars.minor, - patch: vars.patch, - epoch: vars.epoch, - post: vars.post, - dev: vars.dev, - pre_release: vars.pre_release.as_ref().map(|pr| PreReleaseContext { - label: pr.label.label_str().to_string(), - number: pr.number, - }), - distance: vars.distance, - dirty: vars.dirty, - bumped_branch: vars.bumped_branch.clone(), - bumped_commit_hash: vars.bumped_commit_hash.clone(), - bumped_commit_hash_short: vars.get_bumped_commit_hash_short(), - bumped_timestamp: vars.bumped_timestamp, - last_branch: vars.last_branch.clone(), - last_commit_hash: vars.last_commit_hash.clone(), - last_commit_hash_short: vars.get_last_commit_hash_short(), - last_timestamp: vars.last_timestamp, - custom: vars.custom.clone(), - pep440: "".to_string(), - semver: "".to_string(), - } - } +// 3. Use existing parsers +for spec in resolved { + let (index, value) = parse_override_spec(&spec, schema_len)?; + // Apply to schema... } ``` -**File**: `src/cli/utils/template/helpers.rs` (new) - -```rust -use handlebars::{Handlebars, Helper, Context, RenderContext, Output, HelperResult, RenderError}; -use crate::error::ZervError; - -pub fn register_helpers(handlebars: &mut Handlebars) -> Result<(), ZervError> { - handlebars.register_helper("add", Box::new(add_helper)); - handlebars.register_helper("subtract", Box::new(subtract_helper)); - handlebars.register_helper("multiply", Box::new(multiply_helper)); - handlebars.register_helper("hash", Box::new(hash_helper)); - handlebars.register_helper("hash_int", Box::new(hash_int_helper)); - handlebars.register_helper("prefix", Box::new(prefix_helper)); - handlebars.register_helper("format_timestamp", Box::new(format_timestamp_helper)); - Ok(()) -} - -fn add_helper(h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output) -> HelperResult { - let a = h.param(0).and_then(|v| v.value().as_u64()).ok_or_else(|| RenderError::new("First parameter must be a number"))?; - let b = h.param(1).and_then(|v| v.value().as_u64()).ok_or_else(|| RenderError::new("Second parameter must be a number"))?; - out.write(&(a + b).to_string())?; - Ok(()) -} - -fn subtract_helper(h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output) -> HelperResult { - let a = h.param(0).and_then(|v| v.value().as_u64()).ok_or_else(|| RenderError::new("First parameter must be a number"))?; - let b = h.param(1).and_then(|v| v.value().as_u64()).ok_or_else(|| RenderError::new("Second parameter must be a number"))?; - out.write(&a.saturating_sub(b).to_string())?; - Ok(()) -} - -fn multiply_helper(h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output) -> HelperResult { - let a = h.param(0).and_then(|v| v.value().as_u64()).ok_or_else(|| RenderError::new("First parameter must be a number"))?; - let b = h.param(1).and_then(|v| v.value().as_u64()).ok_or_else(|| RenderError::new("Second parameter must be a number"))?; - out.write(&(a * b).to_string())?; - Ok(()) -} - -fn hash_helper(h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output) -> HelperResult { - let input = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| RenderError::new("First parameter must be a string"))?; - let length = h.param(1).and_then(|v| v.value().as_u64()).unwrap_or(7) as usize; - - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = DefaultHasher::new(); - input.hash(&mut hasher); - let hash = hasher.finish(); - let hex = format!("{:x}", hash); - let result = if hex.len() > length { &hex[..length] } else { &hex }; - out.write(result)?; - Ok(()) -} - -fn hash_int_helper(h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output) -> HelperResult { - let input = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| RenderError::new("First parameter must be a string"))?; - let length = h.param(1).and_then(|v| v.value().as_u64()).unwrap_or(7) as usize; - - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - - let mut hasher = DefaultHasher::new(); - input.hash(&mut hasher); - let hash = hasher.finish(); - let result = hash % (10_u64.pow(length as u32)); - out.write(&result.to_string())?; - Ok(()) -} - -fn prefix_helper(h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output) -> HelperResult { - let input = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| RenderError::new("First parameter must be a string"))?; - let length = h.param(1).and_then(|v| v.value().as_u64()).unwrap_or(input.len() as u64) as usize; - - let result = if input.len() > length { &input[..length] } else { input }; - out.write(result)?; - Ok(()) -} +## Implementation Plan -fn format_timestamp_helper(h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output) -> HelperResult { - let timestamp = h.param(0).and_then(|v| v.value().as_u64()).ok_or_else(|| RenderError::new("First parameter must be a timestamp"))?; - let format = h.hash_get("format").and_then(|v| v.value().as_str()).unwrap_or("iso_date"); +### ✅ Step 1: Add Handlebars Dependency - COMPLETED - use chrono::{DateTime, Utc, TimeZone}; - let dt = Utc.timestamp_opt(timestamp as i64, 0).single().ok_or_else(|| RenderError::new("Invalid timestamp"))?; +**Status**: ✅ **COMPLETED** - `handlebars = "^6.3"` already in Cargo.toml - let formatted = match format { - "iso_date" => dt.format("%Y-%m-%d").to_string(), - "iso_datetime" => dt.format("%Y-%m-%dT%H:%M:%S").to_string(), - "compact_date" => dt.format("%Y%m%d").to_string(), - "compact_datetime" => dt.format("%Y%m%d%H%M%S").to_string(), - custom => dt.format(custom).to_string(), - }; +### ✅ Step 2: Template Module Implementation - COMPLETED - out.write(&formatted)?; - Ok(()) -} -``` +**Status**: ✅ **COMPLETED** - All template infrastructure implemented -// No utils.rs needed - use template.resolve(vars) directly -// Option handling can be done inline where needed +### 🔄 Step 3: Update CLI Arguments with Template Types - IN PROGRESS -### Step 3: Update CLI Arguments with Template Types +**Current State**: CLI args use primitive types, need Template wrapper **File**: `src/cli/version/args/main.rs` (update existing) ```rust -use clap::Parser; +// ADD: Import template types use crate::cli::utils::template::Template; -use crate::utils::constants::{ - SUPPORTED_FORMATS_ARRAY, - formats, - sources, -}; - -/// Main configuration for input, schema, and output -#[derive(Parser)] + +// CHANGE: Update output_template field type pub struct MainConfig { // ... existing fields unchanged ... /// Output template for custom formatting (Handlebars syntax) - /// Examples: - /// "{{major}}.{{minor}}.{{patch}}" - /// "v{{major}}.{{minor}}.{{patch}}-{{bumped_branch}}" - /// "{{major}}.{{minor}}.{{patch}}+{{custom.build_id}}" - #[arg( - long, - help = "Output template using Handlebars syntax for custom formatting" - )] - pub output_template: Option>, // UPDATED: was Option + pub output_template: Option>, // CHANGED: was Option // ... rest unchanged ... } @@ -583,204 +101,69 @@ pub struct MainConfig { **File**: `src/cli/version/args/overrides.rs` (update existing) ```rust -use clap::Parser; -use crate::cli::utils::template::{Template, IndexValue}; -use crate::utils::constants::pre_release_labels; +// ADD: Import template types +use crate::cli::utils::template::Template; -/// Override configuration for VCS and version components -#[derive(Parser, Default)] +// CHANGE: Update version component field types pub struct OverridesConfig { // ... VCS override options unchanged ... - // ============================================================================ - // VERSION COMPONENT OVERRIDE OPTIONS (UPDATED WITH TEMPLATE SUPPORT) - // ============================================================================ - /// Override major version number (supports Handlebars templating) - /// Examples: --major 2, --major "{{add major 1}}", --major "{{custom.version}}" - #[arg(long, help = "Override major version number (supports Handlebars templating)")] - pub major: Option>, // UPDATED: was Option - - /// Override minor version number (supports Handlebars templating) - #[arg(long, help = "Override minor version number (supports Handlebars templating)")] - pub minor: Option>, // UPDATED: was Option - - /// Override patch version number (supports Handlebars templating) - #[arg(long, help = "Override patch version number (supports Handlebars templating)")] - pub patch: Option>, // UPDATED: was Option - - /// Override epoch number (supports Handlebars templating) - #[arg(long, help = "Override epoch number (supports Handlebars templating)")] - pub epoch: Option>, // UPDATED: was Option - - /// Override post number (supports Handlebars templating) - #[arg(long, help = "Override post number (supports Handlebars templating)")] - pub post: Option>, // UPDATED: was Option - - /// Override dev number (supports Handlebars templating) - #[arg(long, help = "Override dev number (supports Handlebars templating)")] - pub dev: Option>, // UPDATED: was Option + // VERSION COMPONENT OVERRIDES - CHANGE TO TEMPLATE TYPES + pub major: Option>, // CHANGED: was Option + pub minor: Option>, // CHANGED: was Option + pub patch: Option>, // CHANGED: was Option + pub epoch: Option>, // CHANGED: was Option + pub post: Option>, // CHANGED: was Option + pub dev: Option>, // CHANGED: was Option + pub pre_release_num: Option>, // CHANGED: was Option - /// Override pre-release number (supports Handlebars templating) - #[arg(long, help = "Override pre-release number (supports Handlebars templating)")] - pub pre_release_num: Option>, // UPDATED: was Option + // SCHEMA COMPONENT OVERRIDES - CHANGE TO TEMPLATE TYPES + pub core: Vec>, // CHANGED: was Vec + pub extra_core: Vec>, // CHANGED: was Vec + pub build: Vec>, // CHANGED: was Vec // ... other fields unchanged ... - - // ============================================================================ - // SCHEMA COMPONENT OVERRIDE OPTIONS (UPDATED WITH TEMPLATE SUPPORT) - // ============================================================================ - /// Override core schema component by index=value (VALUE supports Handlebars) - /// Examples: - /// --core "0={{major}}" (use current major) - /// --core "1={{bumped_branch}}" (use current branch) - /// --core "2=v{{major}}.{{minor}}" (template in value) - #[arg( - long, - value_name = "INDEX=VALUE", - num_args = 1.., - help = "Override core schema component by index=value (VALUE supports Handlebars templating)" - )] - pub core: Vec, // UPDATED: was Vec - - /// Override extra-core schema component by index=value (VALUE supports Handlebars) - #[arg( - long, - value_name = "INDEX=VALUE", - num_args = 1.., - help = "Override extra-core schema component by index=value (VALUE supports Handlebars templating)" - )] - pub extra_core: Vec, // UPDATED: was Vec - - /// Override build schema component by index=value (VALUE supports Handlebars) - #[arg( - long, - value_name = "INDEX=VALUE", - num_args = 1.., - help = "Override build schema component by index=value (VALUE supports Handlebars templating)" - )] - pub build: Vec, // UPDATED: was Vec } ``` -### Step 4: Update Bump Arguments with Template Types - **File**: `src/cli/version/args/bumps.rs` (update existing) ```rust -use clap::Parser; +// ADD: Import template types use crate::cli::utils::template::Template; -use crate::utils::constants::pre_release_labels; -/// Bump configuration for field-based and schema-based version bumping -#[derive(Parser, Default)] +// CHANGE: Update bump field types pub struct BumpsConfig { - // ============================================================================ - // FIELD-BASED BUMP OPTIONS (UPDATED WITH TEMPLATE SUPPORT) - // ============================================================================ - /// Add to major version (supports Handlebars templating, default: 1) - /// Examples: --bump-major, --bump-major 2, --bump-major "{{distance}}" - #[arg(long, help = "Add to major version (supports Handlebars templating, default: 1)")] - pub bump_major: Option>>, // UPDATED: was Option> - - /// Add to minor version (supports Handlebars templating, default: 1) - #[arg(long, help = "Add to minor version (supports Handlebars templating, default: 1)")] - pub bump_minor: Option>>, // UPDATED: was Option> - - /// Add to patch version (supports Handlebars templating, default: 1) - #[arg(long, help = "Add to patch version (supports Handlebars templating, default: 1)")] - pub bump_patch: Option>>, // UPDATED: was Option> - - /// Add to post number (supports Handlebars templating, default: 1) - #[arg(long, help = "Add to post number (supports Handlebars templating, default: 1)")] - pub bump_post: Option>>, // UPDATED: was Option> - - /// Add to dev number (supports Handlebars templating, default: 1) - #[arg(long, help = "Add to dev number (supports Handlebars templating, default: 1)")] - pub bump_dev: Option>>, // UPDATED: was Option> - - /// Add to pre-release number (supports Handlebars templating, default: 1) - #[arg(long, help = "Add to pre-release number (supports Handlebars templating, default: 1)")] - pub bump_pre_release_num: Option>>, // UPDATED: was Option> - - /// Add to epoch number (supports Handlebars templating, default: 1) - #[arg(long, help = "Add to epoch number (supports Handlebars templating, default: 1)")] - pub bump_epoch: Option>>, // UPDATED: was Option> + // FIELD-BASED BUMP OPTIONS - CHANGE TO TEMPLATE TYPES + pub bump_major: Option>>, // CHANGED: was Option> + pub bump_minor: Option>>, // CHANGED: was Option> + pub bump_patch: Option>>, // CHANGED: was Option> + pub bump_post: Option>>, // CHANGED: was Option> + pub bump_dev: Option>>, // CHANGED: was Option> + pub bump_pre_release_num: Option>>, // CHANGED: was Option> + pub bump_epoch: Option>>, // CHANGED: was Option> + + // SCHEMA-BASED BUMP OPTIONS - CHANGE TO TEMPLATE TYPES + pub bump_core: Vec>, // CHANGED: was Vec + pub bump_extra_core: Vec>, // CHANGED: was Vec + pub bump_build: Vec>, // CHANGED: was Vec // ... rest unchanged ... } ``` -### Step 5: Update Module Exports - -**File**: `src/cli/utils/mod.rs` (update existing) - -```rust -pub mod format_handler; -pub mod output_formatter; -pub mod template; // NEW: Add template module -``` - -**File**: `src/error.rs` (update existing) - -Add template error variant: - -```rust -#[derive(Debug, thiserror::Error)] -pub enum ZervError { - // ... existing variants ... - - #[error("Template error: {0}")] - TemplateError(String), // NEW: Add template error -} -``` - -### Step 6: Pipeline Integration with Render Timing - -**File**: `src/cli/version/pipeline.rs` (update existing) - -```rust -use crate::cli::utils::template::Template; +### 📋 Step 4: Pipeline Integration with Render Timing - PENDING -pub fn run_version_pipeline(mut args: VersionArgs) -> Result { - // 0. Early validation - fail fast on conflicting options - args.validate()?; - - // 1. Determine working directory - let work_dir = match args.main.directory.as_deref() { - Some(dir) => std::path::PathBuf::from(dir), - None => current_dir()?, - }; - - // 2. Get ZervDraft from source (no schema applied yet) - let zerv_draft = match args.main.source.as_str() { - sources::GIT => super::git_pipeline::process_git_source(&work_dir, &args)?, - sources::STDIN => super::stdin_pipeline::process_stdin_source(&args)?, - source => return Err(ZervError::UnknownSource(source.to_string())), - }; +**Key Architecture: ResolvedArgs Pattern** - // 3. Convert to Zerv (EARLY RENDERING happens inside to_zerv) - let zerv_object = zerv_draft.to_zerv(&args)?; - - // 4. Apply output formatting (LATE RENDERING for output_template) - let output = OutputFormatter::format_output( - &zerv_object, - &args.main.output_format, - args.main.output_prefix.as_deref(), - &args.main.output_template, // Now Option> - )?; - - Ok(output) -} -``` - -### Step 8: Template Resolution and ResolvedArgs +To handle template resolution correctly, we need a ResolvedArgs pattern: **File**: `src/cli/version/args/resolved.rs` (new) ```rust use crate::cli::utils::template::Template; use crate::cli::version::args::{VersionArgs, MainConfig}; -use crate::version::zerv::vars::ZervVars; +use crate::version::Zerv; use crate::error::ZervError; /// Resolved version of VersionArgs with templates rendered @@ -802,7 +185,6 @@ pub struct ResolvedOverrides { pub core: Vec, // Resolved INDEX=VALUE strings pub extra_core: Vec, pub build: Vec, - // ... other non-template fields unchanged } /// Resolved bumps with all templates rendered to values @@ -814,81 +196,72 @@ pub struct ResolvedBumps { pub bump_post: Option>, pub bump_dev: Option>, pub bump_pre_release_num: Option>, - // ... other non-template fields unchanged } impl ResolvedArgs { - /// Resolve all templates in VersionArgs using ZervVars snapshot - pub fn resolve(args: &VersionArgs, vars: &ZervVars) -> Result { - let overrides = ResolvedOverrides::resolve(&args.overrides, vars)?; - let bumps = ResolvedBumps::resolve(&args.bumps, vars)?; + /// Resolve all templates in VersionArgs using Zerv snapshot + pub fn resolve(args: &VersionArgs, zerv: &Zerv) -> Result { + let overrides = ResolvedOverrides::resolve(&args.overrides, zerv)?; + let bumps = ResolvedBumps::resolve(&args.bumps, zerv)?; Ok(ResolvedArgs { overrides, bumps, - main: args.main.clone(), // Keep entire MainConfig + main: args.main.clone(), }) } - - /// Delegate to main config for method access - pub fn dirty_override(&self) -> bool { - self.main.dirty_override() - } } impl ResolvedOverrides { - fn resolve(overrides: &OverridesConfig, vars: &ZervVars) -> Result { + fn resolve(overrides: &OverridesConfig, zerv: &Zerv) -> Result { Ok(ResolvedOverrides { - major: Self::resolve_template(&overrides.major, vars)?, - minor: Self::resolve_template(&overrides.minor, vars)?, - patch: Self::resolve_template(&overrides.patch, vars)?, - epoch: Self::resolve_template(&overrides.epoch, vars)?, - post: Self::resolve_template(&overrides.post, vars)?, - dev: Self::resolve_template(&overrides.dev, vars)?, - pre_release_num: Self::resolve_template(&overrides.pre_release_num, vars)?, - core: Self::resolve_index_values(&overrides.core, vars)?, - extra_core: Self::resolve_index_values(&overrides.extra_core, vars)?, - build: Self::resolve_index_values(&overrides.build, vars)?, + major: Self::resolve_template(&overrides.major, zerv)?, + minor: Self::resolve_template(&overrides.minor, zerv)?, + patch: Self::resolve_template(&overrides.patch, zerv)?, + epoch: Self::resolve_template(&overrides.epoch, zerv)?, + post: Self::resolve_template(&overrides.post, zerv)?, + dev: Self::resolve_template(&overrides.dev, zerv)?, + pre_release_num: Self::resolve_template(&overrides.pre_release_num, zerv)?, + core: Self::resolve_template_strings(&overrides.core, zerv)?, + extra_core: Self::resolve_template_strings(&overrides.extra_core, zerv)?, + build: Self::resolve_template_strings(&overrides.build, zerv)?, }) } - fn resolve_template(template: &Option>, vars: &ZervVars) -> Result, ZervError> + fn resolve_template(template: &Option>, zerv: &Zerv) -> Result, ZervError> where T: FromStr + Clone, T::Err: Display, { match template { - Some(t) => Ok(Some(t.resolve(vars)?)), + Some(t) => Ok(Some(t.resolve(zerv)?)), None => Ok(None), } } - fn resolve_index_values(index_values: &[IndexValue], vars: &ZervVars) -> Result, ZervError> { - index_values.iter() - .map(|iv| { - let resolved_value = iv.value.resolve(vars)?; - Ok(format!("{}={}", iv.index, resolved_value)) - }) + fn resolve_template_strings(templates: &[Template], zerv: &Zerv) -> Result, ZervError> { + templates.iter() + .map(|template| template.resolve(zerv)) .collect() } } impl ResolvedBumps { - fn resolve(bumps: &BumpsConfig, vars: &ZervVars) -> Result { + fn resolve(bumps: &BumpsConfig, zerv: &Zerv) -> Result { Ok(ResolvedBumps { - bump_major: Self::resolve_bump(&bumps.bump_major, vars)?, - bump_minor: Self::resolve_bump(&bumps.bump_minor, vars)?, - bump_patch: Self::resolve_bump(&bumps.bump_patch, vars)?, - bump_epoch: Self::resolve_bump(&bumps.bump_epoch, vars)?, - bump_post: Self::resolve_bump(&bumps.bump_post, vars)?, - bump_dev: Self::resolve_bump(&bumps.bump_dev, vars)?, - bump_pre_release_num: Self::resolve_bump(&bumps.bump_pre_release_num, vars)?, + bump_major: Self::resolve_bump(&bumps.bump_major, zerv)?, + bump_minor: Self::resolve_bump(&bumps.bump_minor, zerv)?, + bump_patch: Self::resolve_bump(&bumps.bump_patch, zerv)?, + bump_epoch: Self::resolve_bump(&bumps.bump_epoch, zerv)?, + bump_post: Self::resolve_bump(&bumps.bump_post, zerv)?, + bump_dev: Self::resolve_bump(&bumps.bump_dev, zerv)?, + bump_pre_release_num: Self::resolve_bump(&bumps.bump_pre_release_num, zerv)?, }) } - fn resolve_bump(bump: &Option>>, vars: &ZervVars) -> Result>, ZervError> { + fn resolve_bump(bump: &Option>>, zerv: &Zerv) -> Result>, ZervError> { match bump { - Some(Some(template)) => Ok(Some(Some(template.resolve(vars)?))), + Some(Some(template)) => Ok(Some(Some(template.resolve(zerv)?))), Some(None) => Ok(Some(None)), None => Ok(None), } @@ -896,158 +269,29 @@ impl ResolvedBumps { } ``` -**File**: `src/version/zerv_draft.rs` (update existing) - -````rust -use crate::cli::version::args::{VersionArgs, resolved::ResolvedArgs}; - -impl ZervDraft { - pub fn to_zerv(mut self, args: &VersionArgs) -> Result { - // Clone vars to create snapshot (prevent order-dependent template resolution) - let vars_snapshot = self.vars.clone(); - - // Resolve ALL templates at once using same snapshot - let resolved_args = ResolvedArgs::resolve(args, &vars_snapshot)?; - - // Apply overrides first (now uses resolved values) - self.vars.apply_context_overrides(&resolved_args)?; - - // Then create the Zerv object (preserve existing logic) - let (schema_name, schema_ron) = args.resolve_schema(); - let mut zerv = self.create_zerv_version(schema_name, schema_ron)?; - - // Apply component processing (bumps with reset logic) (now uses resolved values) - zerv.apply_component_processing(&resolved_args)?; - zerv.normalize(); - - Ok(zerv) - } - - // Keep existing create_zerv_version method unchanged - pub fn create_zerv_version( - self, - schema_name: Option<&str>, - schema_ron: Option<&str>, - ) -> Result { - // Existing implementation unchanged... - let schema = match (schema_name, schema_ron) { - (None, Some(ron_str)) => parse_ron_schema(ron_str)?, - (Some(name), None) => { - if let Some(schema) = get_preset_schema(name, &self.vars) { - schema - } else { - return Err(ZervError::UnknownSchema(name.to_string())); - } - } - (Some(_), Some(_)) => { - return Err(ZervError::ConflictingSchemas( - "Cannot specify both schema_name and schema_ron".to_string(), - )); - } - (None, None) => { - if let Some(existing_schema) = self.schema { - existing_schema - } else { - return Err(ZervError::MissingSchema( - "Either schema_name or schema_ron must be provided".to_string(), - )); - } - } - }; - - Zerv::new(schema, self.vars) - } -} - -### Step 7: Update Existing Methods to Use ResolvedArgs - -**File**: `src/version/zerv/vars.rs` (update existing) +**File**: `src/cli/version/pipeline.rs` (update existing) ```rust -use crate::cli::version::args::resolved::ResolvedArgs; - -impl ZervVars { - /// Apply context overrides using resolved template values - /// This replaces the existing apply_context_overrides method signature - pub fn apply_context_overrides(&mut self, args: &ResolvedArgs) -> Result<(), ZervError> { - // Keep ALL existing override logic, just use resolved values instead of templates - - // VCS overrides (unchanged) - if let Some(tag_version) = &args.main.tag_version { - // Existing tag_version parsing logic... - } - - // Template-resolved version component overrides - if let Some(major) = args.overrides.major { - self.major = Some(major as u64); - } - if let Some(minor) = args.overrides.minor { - self.minor = Some(minor as u64); - } - if let Some(patch) = args.overrides.patch { - self.patch = Some(patch as u64); - } - if let Some(epoch) = args.overrides.epoch { - self.epoch = Some(epoch as u64); - } - if let Some(post) = args.overrides.post { - self.post = Some(post as u64); - } - if let Some(dev) = args.overrides.dev { - self.dev = Some(dev as u64); - } - if let Some(pre_release_num) = args.overrides.pre_release_num { - if let Some(pre_release) = &mut self.pre_release { - pre_release.number = Some(pre_release_num as u64); - } - } - - // Template-resolved schema component overrides - // args.overrides.core/extra_core/build are now Vec with resolved INDEX=VALUE - // Keep existing parsing and application logic... - - // All other existing override logic unchanged... - - Ok(()) - } -} -```` - -**File**: `src/version/zerv/bump/mod.rs` (update existing) +use crate::cli::utils::template::Template; -```rust -use crate::cli::version::args::resolved::ResolvedArgs; - -impl Zerv { - /// Apply component processing using resolved template values - /// This replaces the existing apply_component_processing method signature - pub fn apply_component_processing(&mut self, args: &ResolvedArgs) -> Result<(), ZervError> { - // Keep ALL existing component processing logic, just use resolved values - - // Template-resolved field-based bumps - if let Some(Some(bump_value)) = args.bumps.bump_major { - self.vars.major = Some(self.vars.major.unwrap_or(0) + bump_value as u64); - } else if let Some(None) = args.bumps.bump_major { - self.vars.major = Some(self.vars.major.unwrap_or(0) + 1); - } - // Continue for all bump fields... +pub fn run_version_pipeline(mut args: VersionArgs) -> Result { + // ... existing pipeline logic ... - // Access MainConfig fields as before (unchanged) - if args.main.no_bump_context { - // Keep existing no_bump_context logic - } + // 3. Convert to Zerv (EARLY RENDERING happens inside to_zerv) + let zerv_object = zerv_draft.to_zerv(&args)?; - // Keep ALL existing precedence order logic - // Keep ALL existing reset logic - // Keep ALL existing schema-based bump logic + // 4. Apply output formatting (LATE RENDERING for output_template) + let output = OutputFormatter::format_output( + &zerv_object, + &args.main.output_format, + args.main.output_prefix.as_deref(), + &args.main.output_template, + )?; - Ok(()) - } + Ok(output) } ``` -### Step 8: Update Output Formatter - **File**: `src/cli/utils/output_formatter.rs` (update existing) ```rust @@ -1058,12 +302,11 @@ impl OutputFormatter { zerv: &Zerv, output_format: &str, output_prefix: Option<&str>, - output_template: &Option>, // UPDATED: was &Option + output_template: &Option>, ) -> Result { // Handle template rendering for output let formatted = if let Some(template) = output_template { - // LATE RENDERING: Use final processed ZervVars - template.resolve(&zerv.vars)? + template.resolve(zerv)? } else { // Standard format conversion match output_format { @@ -1095,12 +338,10 @@ impl OutputFormatter { } ``` -## Template Features (Phase 1) +## Template Features ### Available Template Variables -Based on the implemented `TemplateContext`: - **Core Version Fields:** - `{{major}}`, `{{minor}}`, `{{patch}}` - Core version numbers @@ -1161,133 +402,6 @@ Based on the implemented `TemplateContext`: - `{{format_timestamp timestamp format=format_string}}` - Format timestamp -## Key Architectural Decisions - -### ResolvedArgs Structure - -**Decision:** Keep `pub main: MainConfig` instead of extracting individual fields. - -**Benefits:** - -- **Maintains existing schema** - No need to identify which specific fields are used -- **Simpler implementation** - Methods can continue accessing `args.main.input_format`, etc. -- **Future-proof** - New MainConfig fields automatically available -- **Less code changes** - Existing method signatures remain unchanged - -**Implementation:** - -```rust -/// Resolved version of VersionArgs with templates rendered -pub struct ResolvedArgs { - pub overrides: ResolvedOverrides, - pub bumps: ResolvedBumps, - pub main: MainConfig, // Keep entire MainConfig for simplicity -} - -impl ResolvedArgs { - /// Delegate to main config for method access - pub fn dirty_override(&self) -> bool { - self.main.dirty_override() - } -} -``` - -### Unified Early Rendering - -**Problem Solved:** Ensures all early template rendering (overrides + bumps) happens at exactly the same time with the same `ZervVars` snapshot. - -**Implementation:** - -```rust -fn apply_early_template_rendering(&self, args: &VersionArgs, vars: &ZervVars) -> Result { - let mut zerv = self.to_base_zerv(); - - // Both use identical snapshot - guaranteed consistency - self.apply_overrides(&mut zerv, args, vars)?; - self.apply_bumps(&mut zerv, args, vars)?; - - Ok(zerv) -} -``` - -**Benefits:** - -- **Predictable behavior** - All templates see identical context -- **No order dependency** - Overrides don't affect bump calculations -- **Single snapshot** - Eliminates timing-related inconsistencies -- **Atomic operation** - All early rendering happens together - -### Two-Phase Template Rendering - -**Early Phase (before version processing):** - -- **Context:** VCS state + base version from tag -- **Used for:** All overrides and bumps -- **Examples:** `--major "{{distance}}"`, `--bump-patch "{{dev}}"` - -**Late Phase (after version processing):** - -- **Context:** VCS state + fully computed final version -- **Used for:** Output template only -- **Examples:** `--output-template "v{{major}}.{{minor}}.{{patch}}"` - -### Schema Override Simplification - -**Approach:** Render entire `INDEX=VALUE` string as template -**Benefits:** - -- **Keeps existing `Vec` structure** - No CLI changes needed -- **Maximum flexibility** - Can template both index and value -- **Simple implementation** - Single template resolution per override - -**Examples:** - -````bash -zerv --core "0={{major}}" # Template in value -zerv --core "{{custom.index}}=release" # Template in index -zerv --core "{{idx}}={{major}}.{{minor}}" # Template in both -```}}` - Working tree state (true/false) -- `{{bumped_branch}}` - Current branch name -- `{{bumped_commit_hash}}` - Full commit hash -- `{{bumped_commit_hash_short}}` - Short commit hash (7 chars) -- `{{bumped_timestamp}}` - Current commit timestamp - -**Last Version Fields:** - -- `{{last_branch}}` - Branch where last version was created -- `{{last_commit_hash}}` - Last version commit hash -- `{{last_commit_hash_short}}` - Short last commit hash -- `{{last_timestamp}}` - Last version creation timestamp - -**Custom Variables:** - -- `{{custom.field_name}}` - Access custom JSON fields -- `{{custom.build_id}}` - Example: build identifier -- `{{custom.environment}}` - Example: deployment environment - -**Formatted Versions:** - -- `{{pep440}}` - Complete PEP440 formatted version string -- `{{semver}}` - Complete SemVer formatted version string - -### Custom Handlebars Helpers - -**Math Helpers:** - -- `{{add a b}}` - Addition (a + b) -- `{{subtract a b}}` - Subtraction (a - b) -- `{{multiply a b}}` - Multiplication (a \* b) - -**String Helpers:** - -- `{{hash input [length]}}` - Generate hex hash (default: 7 chars) -- `{{hash_int input [length]}}` - Generate integer hash -- `{{prefix string [length]}}` - Get prefix of string to length - -**Timestamp Helpers:** - -- `{{format_timestamp timestamp format=format_string}}` - Format timestamp - **Pre-defined Format Variables:** - `iso_date` - ISO date format (`%Y-%m-%d`) → "2023-12-21" @@ -1301,60 +415,13 @@ zerv --core "{{idx}}={{major}}.{{minor}}" # Template in both **Context**: VCS state + base version from tag **Used for**: All overrides and bumps -**Available vars**: `distance`, `dirty`, `bumped_*`, `last_*`, base version fields (`major`, `minor`, etc. from tag) - -**Examples:** -```bash -# Use VCS distance to set patch bump -zerv --bump-patch "{{distance}}" - -# Use previous dev number as distance override -zerv --distance "{{dev}}" - -# Use branch name in pre-release label -zerv --bump-pre-release-label "{{bumped_branch}}" -```` +**Examples**: `--major "{{distance}}"`, `--bump-patch "{{dev}}"` ### LATE RENDERING (after version processing) **Context**: VCS state + fully computed final version **Used for**: Output template only -**Available vars**: All early vars PLUS computed version fields, formatted versions - -**Examples:** - -```bash -# Output with computed version -zerv --output-template "v{{major}}.{{minor}}.{{patch}}" - -# Complex output with VCS and version data -zerv --output-template "{{semver}}+build.{{bumped_commit_hash_short}}" -``` - -## Implementation Notes - -- **No duplicate functions**: Use `template.resolve(vars)` directly -- **Timing controlled by context**: Which `ZervVars` object is passed -- **No circular dependencies**: Overrides use historical data as input schema component overrides/bumps - **Template Variables**: Current state before any bumping - -```rust -// These render EARLY using current Zerv context: -pub major: Option>, // Field override -pub core: Vec, // Schema override (IndexValue.value is Template) -pub bump_major: Option>>, // Bump values -``` - -### LATE RENDERING (after component processing) - -**Context**: Final processed Zerv object with all bumps applied -**Used for**: Output formatting -**Template Variables**: Final state after all processing - -```rust -// This renders LATE using final Zerv object: -pub output_template: Option>, // Output format -``` +**Examples**: `--output-template "v{{major}}.{{minor}}.{{patch}}"` ## Usage Examples @@ -1363,15 +430,12 @@ pub output_template: Option>, // Output format ```bash # Override version fields with templates zerv version --major "{{add major 1}}" --minor "{{custom.target_minor}}" -# Renders templates using current context, then applies overrides # Override with current VCS context zerv version --dev "{{distance}}" --post "{{add distance 10}}" -# Uses current distance value in templates # Complex field templates zerv version --patch "{{multiply minor 10}}" --epoch "{{custom.release_year}}" -# Mathematical operations and custom variables ``` ### Schema Component Override Templates @@ -1379,11 +443,9 @@ zerv version --patch "{{multiply minor 10}}" --epoch "{{custom.release_year}}" ```bash # Schema component overrides with templates zerv version --core "0={{major}}" --core "1={{bumped_branch}}" -# Use current major version and branch name in schema # Complex schema templates zerv version --extra-core "0={{add post 1}}" --build "0={{hash bumped_commit_hash 8}}" -# Mathematical operations and hash generation ``` ### Bump Templates (EARLY RENDERING) @@ -1391,11 +453,9 @@ zerv version --extra-core "0={{add post 1}}" --build "0={{hash bumped_commit_has ```bash # Bump with template values zerv version --bump-major "{{distance}}" --bump-minor "{{custom.increment}}" -# Use VCS distance and custom increment values # Conditional bumps zerv version --bump-patch "{{#if dirty}}10{{else}}1{{/if}}" -# Different bump values based on dirty state ``` ### Output Templates (LATE RENDERING) @@ -1403,23 +463,46 @@ zerv version --bump-patch "{{#if dirty}}10{{else}}1{{/if}}" ```bash # Custom output format after all processing zerv version --output-template "{{major}}.{{minor}}.{{patch}}-{{bumped_branch}}" -# Renders using final processed Zerv object # Complex output with helpers zerv version --output-template "v{{major}}.{{minor}}.{{patch}}+{{hash bumped_commit_hash 7}}" -# Use hash helper for commit hash # Output with custom variables and timestamps zerv version --output-template "{{major}}.{{minor}}.{{patch}}+{{custom.build_id}}.{{format_timestamp bumped_timestamp format=compact_date}}" -# Access to all final variables and computed fields ``` +## Migration Strategy + +### ✅ Phase 1: Add Template Infrastructure - COMPLETED + +1. ✅ Add handlebars dependency +2. ✅ Implement template types and helpers +3. ✅ Add template module exports + +### 🔄 Phase 2: Update CLI Arguments - IN PROGRESS + +1. 🔄 Update MainConfig.output_template type +2. 🔄 Update OverridesConfig field types +3. 🔄 Update BumpsConfig field types + +### 📋 Phase 3: Pipeline Integration - PENDING + +1. 📋 Add early rendering for overrides/bumps +2. 📋 Add late rendering for output templates +3. 📋 Update output formatter +4. 📋 Add error handling + +### 📋 Phase 4: Testing and Documentation - PENDING + +1. 📋 Add comprehensive unit tests +2. 📋 Add integration tests +3. 📋 Update CLI help text +4. 📋 Add usage examples + ## Testing Strategy ### Unit Tests -**File**: `src/cli/utils/template.rs` (tests) - ```rust #[cfg(test)] mod tests { @@ -1430,50 +513,27 @@ mod tests { fn test_template_value_resolution() { let template = Template::Value(42u32); let zerv = ZervFixture::new().build(); - assert_eq!(template.resolve(&zerv.vars).unwrap(), 42); + assert_eq!(template.resolve(&zerv).unwrap(), 42); } #[test] fn test_template_string_resolution() { let template = Template::Template("{{major}}.{{minor}}.{{patch}}".to_string()); let zerv = ZervFixture::new().with_version(1, 2, 3).build(); - assert_eq!(template.resolve(&zerv.vars).unwrap(), "1.2.3"); + assert_eq!(template.resolve(&zerv).unwrap(), "1.2.3"); } #[test] fn test_template_helpers() { let template = Template::Template("{{add major minor}}".to_string()); let zerv = ZervFixture::new().with_version(1, 2, 3).build(); - assert_eq!(template.resolve(&zerv.vars).unwrap(), "3"); - } - - #[test] - fn test_index_value_parsing() { - let index_value: IndexValue = "0={{major}}".parse().unwrap(); - assert_eq!(index_value.index, 0); - assert_eq!(index_value.value, Template::Template("{{major}}".to_string())); - } - - #[test] - fn test_template_context_creation() { - let zerv = ZervFixture::new() - .with_version(1, 2, 3) - .with_branch("main".to_string()) - .build(); - - let context = TemplateContext::from_zerv_vars(&zerv.vars); - assert_eq!(context.major, Some(1)); - assert_eq!(context.minor, Some(2)); - assert_eq!(context.patch, Some(3)); - assert_eq!(context.bumped_branch, Some("main".to_string())); + assert_eq!(template.resolve(&zerv).unwrap(), "3"); } } ``` ### Integration Tests -**File**: `tests/integration_tests/version/template_integration.rs` (new) - ```rust use zerv::test_utils::*; @@ -1489,61 +549,20 @@ fn test_template_override_integration() { assert_eq!(output.trim(), "v2.0.0"); } - -#[test] -fn test_template_bump_integration() { - let temp_dir = TempDir::new().unwrap(); - let git_repo = GitRepo::new(&temp_dir).with_initial_commit().with_tag("v1.0.0"); - - let output = git_repo.zerv_version(&[ - "--bump-major", "{{distance}}", - "--output-template", "{{major}}.{{minor}}.{{patch}}" - ]).unwrap(); - - // Should bump major by distance (0 in this case) - assert_eq!(output.trim(), "1.0.0"); -} ``` -## Migration Strategy - -### Phase 1: Add Template Infrastructure - -1. Add handlebars dependency -2. Implement template types and helpers -3. Add template module exports - -### Phase 2: Update CLI Arguments - -1. Update MainConfig.output_template type -2. Update OverridesConfig field types -3. Update BumpsConfig field types -4. Update IndexValue to support templates - -### Phase 3: Pipeline Integration - -1. Add early rendering for overrides/bumps -2. Add late rendering for output templates -3. Update output formatter -4. Add error handling - -### Phase 4: Testing and Documentation - -1. Add comprehensive unit tests -2. Add integration tests -3. Update CLI help text -4. Add usage examples - ## Success Criteria -- ✅ Template types replace primitive types in CLI arguments -- ✅ Handlebars templating works with variable substitution -- ✅ Custom helpers implemented and functional -- ✅ Early vs late rendering timing works correctly -- ✅ All existing functionality preserved -- ✅ Template validation and error handling -- ✅ Comprehensive test coverage -- ✅ Clean integration with existing codebase +- ✅ **Handlebars dependency added** - COMPLETED +- ✅ **Template infrastructure implemented** - COMPLETED +- ✅ **Template module exported** - COMPLETED +- ✅ **TemplateError handling added** - COMPLETED +- 🔄 **Template types replace primitive types in CLI arguments** - IN PROGRESS +- 📋 **Early vs late rendering timing works correctly** - PENDING +- 📋 **All existing functionality preserved** - PENDING +- 📋 **Template validation and error handling** - PENDING +- 📋 **Comprehensive test coverage** - PENDING +- 📋 **Clean integration with existing codebase** - PENDINGG ## Benefits @@ -1556,4 +575,26 @@ fn test_template_bump_integration() { 7. **Backward Compatibility**: Existing literal values continue to work 8. **Extensible**: Easy to add new helpers and template variables +## Key Architectural Decisions + +### ResolvedArgs Pattern + +**Decision**: Use ResolvedArgs to separate template resolution from processing +**Benefits**: + +- **Predictable behavior** - All templates see identical context +- **No order dependency** - Overrides don't affect bump calculations +- **Single snapshot** - Eliminates timing-related inconsistencies +- **Atomic operation** - All early rendering happens together + +### Two-Phase Rendering + +**Early Phase**: Before version processing (overrides/bumps) +**Late Phase**: After version processing (output only) +**Benefits**: + +- **Clear separation** - Processing vs output concerns +- **Consistent context** - Each phase has well-defined available variables +- **No circular dependencies** - Overrides use historical data as input + This updated plan leverages the solid foundation from Plans 19-24 and provides a comprehensive templating system that aligns with the ideal specification from Plan 11. diff --git a/src/test_utils/types.rs b/src/test_utils/types.rs index 49dc7b6..10706d2 100644 --- a/src/test_utils/types.rs +++ b/src/test_utils/types.rs @@ -13,8 +13,8 @@ pub enum BumpType { Dev(u64), SchemaBump { section: String, - index: usize, - value: u64, + index: i32, + value: Option, }, } diff --git a/src/test_utils/version_args.rs b/src/test_utils/version_args.rs index 8035ea5..39a00a4 100644 --- a/src/test_utils/version_args.rs +++ b/src/test_utils/version_args.rs @@ -266,8 +266,11 @@ impl VersionArgsFixture { index, value, } => { - // Convert to key=value format - let spec = format!("{index}={value}"); + // Convert to spec format: "0" or "0=5" + let spec = match value { + Some(v) => format!("{index}={v}"), // "0=5" + None => index.to_string(), // "0" + }; match section.as_str() { "core" => { self.args.bumps.bump_core.push(spec); diff --git a/src/version/zerv/bump/mod.rs b/src/version/zerv/bump/mod.rs index 045c91c..d722711 100644 --- a/src/version/zerv/bump/mod.rs +++ b/src/version/zerv/bump/mod.rs @@ -172,4 +172,61 @@ mod tests { let result_version: SemVer = zerv.into(); assert_eq!(result_version.to_string(), expected_version); } + + // Test schema-based bump functionality (Plan 26) + #[rstest] + #[case( + "1.2.3", + vec![BumpType::SchemaBump { section: "core".to_string(), index: 0, value: None }], + "2.0.0" // core[0] (major) bumped by 1, resets minor+patch + )] + #[case( + "1.2.3", + vec![BumpType::SchemaBump { section: "core".to_string(), index: 0, value: Some(5) }], + "6.0.0" // core[0] (major) bumped by 5, resets minor+patch + )] + #[case( + "1.2.3", + vec![BumpType::SchemaBump { section: "core".to_string(), index: 1, value: Some(3) }], + "1.5.0" // core[1] (minor) bumped by 3, resets patch + )] + #[case( + "1.2.3", + vec![BumpType::SchemaBump { section: "core".to_string(), index: -1, value: None }], + "1.2.4" // core[-1] (patch, last component) bumped by 1, no reset + )] + #[case( + "1.2.3", + vec![BumpType::SchemaBump { section: "core".to_string(), index: -1, value: Some(7) }], + "1.2.10" // core[-1] (patch) bumped by 7, no reset + )] + #[case( + "1.2.3", + vec![BumpType::SchemaBump { section: "core".to_string(), index: -2, value: Some(2) }], + "1.4.0" // core[-2] (minor, second-to-last) bumped by 2, resets patch + )] + #[case( + "1.2.3", + vec![ + BumpType::SchemaBump { section: "core".to_string(), index: 0, value: None }, + BumpType::SchemaBump { section: "core".to_string(), index: 1, value: None } + ], + "2.1.0" // major bump resets minor+patch, then minor bump resets patch + )] + fn test_schema_based_bump( + #[case] starting_version: &str, + #[case] bumps: Vec, + #[case] expected_version: &str, + ) { + let mut zerv = ZervFixture::from_semver_str(starting_version) + .with_standard_tier_3() // Schema: [major, minor, patch] + .build(); + + let args = VersionArgsFixture::new().with_bump_specs(bumps).build(); + + zerv.apply_component_processing(&args).unwrap(); + + let result_version: SemVer = zerv.into(); + assert_eq!(result_version.to_string(), expected_version); + } } From 79e6a5c71fb5e1a958a4814426e49b8138a0b8af Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Sat, 18 Oct 2025 17:30:31 +0700 Subject: [PATCH 03/13] feat: complete implementing handlebars --- ...handlebars-cli-integration-updated-plan.md | 83 ++++--- src/cli/utils/output_formatter.rs | 65 ++---- src/cli/utils/template/types.rs | 35 +++ src/cli/version/args/bumps.rs | 27 +-- src/cli/version/args/main.rs | 9 +- src/cli/version/args/mod.rs | 7 + src/cli/version/args/overrides.rs | 27 +-- src/cli/version/args/resolved.rs | 197 ++++++++++++++++ src/cli/version/args/tests/bumps_tests.rs | 36 +-- src/cli/version/args/tests/overrides_tests.rs | 8 +- src/cli/version/args/tests/resolved_tests.rs | 214 ++++++++++++++++++ .../version/args/tests/validation_tests.rs | 22 +- src/cli/version/args/validation.rs | 38 ++-- src/cli/version/pipeline.rs | 4 +- src/cli/version/zerv_draft.rs | 10 +- src/test_utils/version_args.rs | 89 ++++---- src/version/zerv/bump/mod.rs | 16 +- src/version/zerv/bump/vars_secondary.rs | 14 +- src/version/zerv/bump/vars_timestamp.rs | 24 +- 19 files changed, 711 insertions(+), 214 deletions(-) create mode 100644 src/cli/version/args/resolved.rs create mode 100644 src/cli/version/args/tests/resolved_tests.rs diff --git a/.dev/25-handlebars-cli-integration-updated-plan.md b/.dev/25-handlebars-cli-integration-updated-plan.md index 36a3646..b15f224 100644 --- a/.dev/25-handlebars-cli-integration-updated-plan.md +++ b/.dev/25-handlebars-cli-integration-updated-plan.md @@ -25,10 +25,10 @@ Implement Handlebars templating support for CLI arguments based on the completed - `src/cli/utils/mod.rs` - Template module exported - `src/error.rs` - TemplateError variant added -### 🔄 What Needs Implementation (Steps 3-4) +### ✅ Implementation Complete (Steps 3-4) -3. **CLI Integration**: Update argument types to support templating -4. **Pipeline Integration**: Add early/late rendering and update processing logic +3. ✅ **CLI Integration**: All argument types updated to support templating +4. ✅ **Pipeline Integration**: Early/late rendering implemented with ResolvedArgs pattern **Deviations from Plan**: @@ -77,9 +77,21 @@ for spec in resolved { **Status**: ✅ **COMPLETED** - All template infrastructure implemented -### 🔄 Step 3: Update CLI Arguments with Template Types - IN PROGRESS +### ✅ Step 3: Update CLI Arguments with Template Types - COMPLETED -**Current State**: CLI args use primitive types, need Template wrapper +**Status**: ✅ **COMPLETED** - All CLI argument types updated to use Template wrapper + +**Implementation Summary**: + +- Updated `MainConfig::output_template` to `Option>` +- Updated all version component fields in `OverridesConfig` to use `Template` +- Updated all schema component fields to use `Template` +- Updated all bump fields in `BumpsConfig` to use `Template` and `Template` +- Created `ResolvedArgs` pattern for template resolution +- Updated pipeline to resolve templates before processing +- Updated bump processing to use resolved args + +**Test Fixes Needed**: Tests need updates to use `Template::Value()` wrapper for assertions **File**: `src/cli/version/args/main.rs` (update existing) @@ -152,11 +164,11 @@ pub struct BumpsConfig { } ``` -### 📋 Step 4: Pipeline Integration with Render Timing - PENDING +### ✅ Step 4: Pipeline Integration with Render Timing - COMPLETED -**Key Architecture: ResolvedArgs Pattern** +**Key Architecture: ResolvedArgs Pattern** ✅ IMPLEMENTED -To handle template resolution correctly, we need a ResolvedArgs pattern: +Template resolution is handled through the ResolvedArgs pattern: **File**: `src/cli/version/args/resolved.rs` (new) @@ -479,25 +491,29 @@ zerv version --output-template "{{major}}.{{minor}}.{{patch}}+{{custom.build_id} 2. ✅ Implement template types and helpers 3. ✅ Add template module exports -### 🔄 Phase 2: Update CLI Arguments - IN PROGRESS +### ✅ Phase 2: Update CLI Arguments - COMPLETED -1. 🔄 Update MainConfig.output_template type -2. 🔄 Update OverridesConfig field types -3. 🔄 Update BumpsConfig field types +1. ✅ Update MainConfig.output_template type +2. ✅ Update OverridesConfig field types +3. ✅ Update BumpsConfig field types +4. ✅ Add From trait implementations for Template types -### 📋 Phase 3: Pipeline Integration - PENDING +### ✅ Phase 3: Pipeline Integration - COMPLETED -1. 📋 Add early rendering for overrides/bumps -2. 📋 Add late rendering for output templates -3. 📋 Update output formatter -4. 📋 Add error handling +1. ✅ Add ResolvedArgs pattern for template resolution +2. ✅ Add early rendering for overrides/bumps +3. ✅ Add late rendering for output templates +4. ✅ Update output formatter with Template support +5. ✅ Update pipeline with proper render timing +6. ✅ Add error handling -### 📋 Phase 4: Testing and Documentation - PENDING +### ✅ Phase 4: Testing and Documentation - COMPLETED -1. 📋 Add comprehensive unit tests -2. 📋 Add integration tests -3. 📋 Update CLI help text -4. 📋 Add usage examples +1. ✅ Add comprehensive unit tests for template types +2. ✅ Add ResolvedArgs tests with rstest +3. ✅ Add output formatter tests +4. ✅ Update all existing tests to work with Template types +5. ✅ Add template resolution test coverage ## Testing Strategy @@ -557,12 +573,23 @@ fn test_template_override_integration() { - ✅ **Template infrastructure implemented** - COMPLETED - ✅ **Template module exported** - COMPLETED - ✅ **TemplateError handling added** - COMPLETED -- 🔄 **Template types replace primitive types in CLI arguments** - IN PROGRESS -- 📋 **Early vs late rendering timing works correctly** - PENDING -- 📋 **All existing functionality preserved** - PENDING -- 📋 **Template validation and error handling** - PENDING -- 📋 **Comprehensive test coverage** - PENDING -- 📋 **Clean integration with existing codebase** - PENDINGG +- ✅ **Template types replace primitive types in CLI arguments** - COMPLETED +- ✅ **Early vs late rendering timing works correctly** - COMPLETED +- ✅ **All existing functionality preserved** - COMPLETED +- ✅ **Template validation and error handling** - COMPLETED +- ✅ **Comprehensive test coverage** - COMPLETED +- ✅ **Clean integration with existing codebase** - COMPLETED + +## 🎉 Implementation Complete! + +The Handlebars CLI integration has been successfully implemented with all success criteria met. The system now supports: + +- **Full template support** for all CLI arguments +- **Proper render timing** (early for processing, late for output) +- **Type-safe template resolution** with the ResolvedArgs pattern +- **Comprehensive test coverage** including unit and integration tests +- **Backward compatibility** with existing literal values +- **Clean architecture** that maintains separation of concerns ## Benefits diff --git a/src/cli/utils/output_formatter.rs b/src/cli/utils/output_formatter.rs index ce06025..0bbeba2 100644 --- a/src/cli/utils/output_formatter.rs +++ b/src/cli/utils/output_formatter.rs @@ -1,3 +1,4 @@ +use crate::cli::utils::template::Template; use crate::error::ZervError; use crate::utils::constants::{ SUPPORTED_FORMATS, @@ -16,17 +17,16 @@ impl OutputFormatter { zerv_object: &Zerv, output_format: &str, output_prefix: Option<&str>, - output_template: Option<&str>, + output_template: &Option>, ) -> Result { - // 1. Generate base output according to format - let mut output = Self::format_base_output(zerv_object, output_format)?; - - // 2. Apply template if specified (future extension) - if let Some(template) = output_template { - output = Self::apply_template(&output, template, zerv_object)?; - } + // 1. Resolve template if provided, otherwise use standard format + let mut output = if let Some(template) = output_template { + template.resolve(zerv_object)? + } else { + Self::format_base_output(zerv_object, output_format)? + }; - // 3. Apply prefix if specified + // 2. Apply prefix if specified if let Some(prefix) = output_prefix { output = format!("{prefix}{output}"); } @@ -48,22 +48,6 @@ impl OutputFormatter { } } - /// Apply template to the output (basic infrastructure for future extension) - fn apply_template( - base_output: &str, - template: &str, - _zerv_object: &Zerv, - ) -> Result { - // Basic template support - for now just replace {version} placeholder - if template.contains("{version}") { - Ok(template.replace("{version}", base_output)) - } else { - // If no {version} placeholder, just return the template as-is - // This allows for simple prefix/suffix templates - Ok(template.to_string()) - } - } - /// Get list of supported output formats pub fn supported_formats() -> &'static [&'static str] { SUPPORTED_FORMATS @@ -118,7 +102,7 @@ mod tests { #[case(formats::PEP440, "1.2.3")] fn test_format_output_basic_formats(#[case] format: &str, #[case] expected: &str) { let zerv = create_test_zerv(); - let result = OutputFormatter::format_output(&zerv, format, None, None); + let result = OutputFormatter::format_output(&zerv, format, None, &None); assert!(result.is_ok(), "Formatting should succeed"); let output = result.unwrap(); @@ -129,7 +113,7 @@ mod tests { #[test] fn test_format_output_zerv() { let zerv = create_test_zerv(); - let result = OutputFormatter::format_output(&zerv, formats::ZERV, None, None); + let result = OutputFormatter::format_output(&zerv, formats::ZERV, None, &None); assert!(result.is_ok(), "Zerv formatting should succeed"); let output = result.unwrap(); @@ -155,15 +139,20 @@ mod tests { #[rstest] #[case(Some("v"), None, "v1.2.3")] - #[case(None, Some("Version: {version}"), "Version: 1.2.3")] - #[case(Some("Release "), Some("{version}-final"), "Release 1.2.3-final")] + #[case(None, Some("{{major}}.{{minor}}.{{patch}}"), "1.2.3")] + #[case( + Some("Release "), + Some("v{{major}}.{{minor}}.{{patch}}-final"), + "Release v1.2.3-final" + )] fn test_format_output_with_options( #[case] prefix: Option<&str>, #[case] template: Option<&str>, #[case] expected: &str, ) { let zerv = create_test_zerv(); - let result = OutputFormatter::format_output(&zerv, formats::SEMVER, prefix, template); + let template_obj = template.map(|t| t.into()); + let result = OutputFormatter::format_output(&zerv, formats::SEMVER, prefix, &template_obj); assert!(result.is_ok(), "Formatting should succeed"); let output = result.unwrap(); @@ -173,7 +162,7 @@ mod tests { #[test] fn test_format_output_unknown_format() { let zerv = create_test_zerv(); - let result = OutputFormatter::format_output(&zerv, "unknown", None, None); + let result = OutputFormatter::format_output(&zerv, "unknown", None, &None); assert!(result.is_err(), "Unknown format should fail"); assert!(matches!(result, Err(ZervError::UnknownFormat(_)))); } @@ -186,18 +175,4 @@ mod tests { assert!(formats.contains(&formats::ZERV)); assert_eq!(formats.len(), 3); } - - #[rstest] - #[case("1.2.3", "custom-output", "custom-output")] - #[case("1.2.3", "Version {version} ready", "Version 1.2.3 ready")] - fn test_apply_template( - #[case] base_output: &str, - #[case] template: &str, - #[case] expected: &str, - ) { - let zerv = create_test_zerv(); - let result = OutputFormatter::apply_template(base_output, template, &zerv); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), expected); - } } diff --git a/src/cli/utils/template/types.rs b/src/cli/utils/template/types.rs index daf5ba9..cc1d041 100644 --- a/src/cli/utils/template/types.rs +++ b/src/cli/utils/template/types.rs @@ -51,6 +51,41 @@ where } } +impl From for Template { + fn from(value: String) -> Self { + if value.contains("{{") && value.contains("}}") { + Template::Template(value) + } else { + Template::Value(value) + } + } +} + +impl From<&str> for Template { + fn from(value: &str) -> Self { + value.to_string().into() + } +} + +impl From for Template { + fn from(value: u32) -> Self { + Template::Value(value) + } +} + +impl From<&str> for Template { + fn from(value: &str) -> Self { + if value.contains("{{") && value.contains("}}") { + Template::Template(value.to_string()) + } else { + match value.parse::() { + Ok(parsed) => Template::Value(parsed), + Err(_) => Template::Template(value.to_string()), // Fallback to template + } + } + } +} + impl FromStr for Template where T: FromStr, diff --git a/src/cli/version/args/bumps.rs b/src/cli/version/args/bumps.rs index df09edb..4ba5611 100644 --- a/src/cli/version/args/bumps.rs +++ b/src/cli/version/args/bumps.rs @@ -1,5 +1,6 @@ use clap::Parser; +use crate::cli::utils::template::Template; use crate::utils::constants::pre_release_labels; /// Bump configuration for field-based and schema-based version bumping @@ -10,31 +11,31 @@ pub struct BumpsConfig { // ============================================================================ /// Add to major version (default: 1) #[arg(long, help = "Add to major version (default: 1)")] - pub bump_major: Option>, + pub bump_major: Option>>, /// Add to minor version (default: 1) #[arg(long, help = "Add to minor version (default: 1)")] - pub bump_minor: Option>, + pub bump_minor: Option>>, /// Add to patch version (default: 1) #[arg(long, help = "Add to patch version (default: 1)")] - pub bump_patch: Option>, + pub bump_patch: Option>>, /// Add to post number (default: 1) #[arg(long, help = "Add to post number (default: 1)")] - pub bump_post: Option>, + pub bump_post: Option>>, /// Add to dev number (default: 1) #[arg(long, help = "Add to dev number (default: 1)")] - pub bump_dev: Option>, + pub bump_dev: Option>>, /// Add to pre-release number (default: 1) #[arg(long, help = "Add to pre-release number (default: 1)")] - pub bump_pre_release_num: Option>, + pub bump_pre_release_num: Option>>, /// Add to epoch number (default: 1) #[arg(long, help = "Add to epoch number (default: 1)")] - pub bump_epoch: Option>, + pub bump_epoch: Option>>, /// Bump pre-release label (alpha, beta, rc) and reset number to 0 #[arg(long, value_parser = clap::builder::PossibleValuesParser::new(pre_release_labels::VALID_LABELS), @@ -49,27 +50,27 @@ pub struct BumpsConfig { long, value_name = "INDEX[=VALUE]", num_args = 1.., - help = "Bump core schema component by index[=value] (e.g., --bump-core 0=5 or --bump-core 0)" + help = "Bump core schema component by index[=value] (e.g., --bump-core 0={{distance}} or --bump-core 0)" )] - pub bump_core: Vec, + pub bump_core: Vec>, /// Bump extra-core schema component by index[=value] (default value: 1) #[arg( long, value_name = "INDEX[=VALUE]", num_args = 1.., - help = "Bump extra-core schema component by index[=value] (e.g., --bump-extra-core 0=5 or --bump-extra-core 0)" + help = "Bump extra-core schema component by index[=value] (e.g., --bump-extra-core 0={{distance}} or --bump-extra-core 0)" )] - pub bump_extra_core: Vec, + pub bump_extra_core: Vec>, /// Bump build schema component by index[=value] (default value: 1) #[arg( long, value_name = "INDEX[=VALUE]", num_args = 1.., - help = "Bump build schema component by index[=value] (e.g., --bump-build 0=5 or --bump-build 0)" + help = "Bump build schema component by index[=value] (e.g., --bump-build 0={{distance}} or --bump-build 0)" )] - pub bump_build: Vec, + pub bump_build: Vec>, // ============================================================================ // CONTEXT CONTROL OPTIONS diff --git a/src/cli/version/args/main.rs b/src/cli/version/args/main.rs index 4cf8acf..266e681 100644 --- a/src/cli/version/args/main.rs +++ b/src/cli/version/args/main.rs @@ -1,5 +1,6 @@ use clap::Parser; +use crate::cli::utils::template::Template; use crate::utils::constants::{ SUPPORTED_FORMATS_ARRAY, formats, @@ -7,7 +8,7 @@ use crate::utils::constants::{ }; /// Main configuration for input, schema, and output -#[derive(Parser)] +#[derive(Parser, Debug, Clone)] pub struct MainConfig { // ============================================================================ // 1. INPUT CONTROL @@ -45,12 +46,12 @@ pub struct MainConfig { help = format!("Output format: '{}' (default), '{}', or '{}' (RON format for piping)", formats::SEMVER, formats::PEP440, formats::ZERV))] pub output_format: String, - /// Output template for custom formatting (future extension) + /// Output template for custom formatting (Handlebars syntax) #[arg( long, - help = "Output template for custom formatting (future extension)" + help = "Output template for custom formatting (Handlebars syntax)" )] - pub output_template: Option, + pub output_template: Option>, /// Prefix to add to output #[arg( diff --git a/src/cli/version/args/mod.rs b/src/cli/version/args/mod.rs index 12e9848..c76aac0 100644 --- a/src/cli/version/args/mod.rs +++ b/src/cli/version/args/mod.rs @@ -3,6 +3,7 @@ use clap::Parser; pub mod bumps; pub mod main; pub mod overrides; +pub mod resolved; pub mod validation; #[cfg(test)] @@ -11,12 +12,18 @@ mod tests { pub mod combination_tests; pub mod main_tests; pub mod overrides_tests; + pub mod resolved_tests; pub mod validation_tests; } pub use bumps::BumpsConfig; pub use main::MainConfig; pub use overrides::OverridesConfig; +pub use resolved::{ + ResolvedArgs, + ResolvedBumps, + ResolvedOverrides, +}; use validation::Validation; /// Generate version from VCS data diff --git a/src/cli/version/args/overrides.rs b/src/cli/version/args/overrides.rs index a7ece8c..042bf5f 100644 --- a/src/cli/version/args/overrides.rs +++ b/src/cli/version/args/overrides.rs @@ -1,5 +1,6 @@ use clap::Parser; +use crate::cli::utils::template::Template; use crate::utils::constants::pre_release_labels; /// Override configuration for VCS and version components @@ -50,27 +51,27 @@ pub struct OverridesConfig { // ============================================================================ /// Override major version number #[arg(long, help = "Override major version number")] - pub major: Option, + pub major: Option>, /// Override minor version number #[arg(long, help = "Override minor version number")] - pub minor: Option, + pub minor: Option>, /// Override patch version number #[arg(long, help = "Override patch version number")] - pub patch: Option, + pub patch: Option>, /// Override epoch number #[arg(long, help = "Override epoch number")] - pub epoch: Option, + pub epoch: Option>, /// Override post number #[arg(long, help = "Override post number")] - pub post: Option, + pub post: Option>, /// Override dev number #[arg(long, help = "Override dev number")] - pub dev: Option, + pub dev: Option>, /// Override pre-release label #[arg(long, value_parser = clap::builder::PossibleValuesParser::new(pre_release_labels::VALID_LABELS), @@ -79,7 +80,7 @@ pub struct OverridesConfig { /// Override pre-release number #[arg(long, help = "Override pre-release number")] - pub pre_release_num: Option, + pub pre_release_num: Option>, /// Override custom variables in JSON format #[arg(long, help = "Override custom variables in JSON format")] @@ -93,27 +94,27 @@ pub struct OverridesConfig { long, value_name = "INDEX=VALUE", num_args = 1.., - help = "Override core schema component by index=value (e.g., --core 0=5 or --core 1=release)" + help = "Override core schema component by index=value (e.g., --core 0=5 or --core 1={{major}})" )] - pub core: Vec, + pub core: Vec>, /// Override extra-core schema component by index=value #[arg( long, value_name = "INDEX=VALUE", num_args = 1.., - help = "Override extra-core schema component by index=value (e.g., --extra-core 0=5 or --extra-core 1=beta)" + help = "Override extra-core schema component by index=value (e.g., --extra-core 0=5 or --extra-core 1={{branch}})" )] - pub extra_core: Vec, + pub extra_core: Vec>, /// Override build schema component by index=value #[arg( long, value_name = "INDEX=VALUE", num_args = 1.., - help = "Override build schema component by index=value (e.g., --build 0=5 or --build 1=main)" + help = "Override build schema component by index=value (e.g., --build 0=5 or --build 1={{commit_short}})" )] - pub build: Vec, + pub build: Vec>, } impl OverridesConfig { diff --git a/src/cli/version/args/resolved.rs b/src/cli/version/args/resolved.rs new file mode 100644 index 0000000..212e56a --- /dev/null +++ b/src/cli/version/args/resolved.rs @@ -0,0 +1,197 @@ +use std::fmt::Display; +use std::str::FromStr; + +use super::{ + BumpsConfig, + MainConfig, + OverridesConfig, + VersionArgs, +}; +use crate::cli::utils::template::Template; +use crate::error::ZervError; +use crate::version::Zerv; + +/// Resolved version of VersionArgs with templates rendered +#[derive(Debug, Clone)] +pub struct ResolvedArgs { + pub overrides: ResolvedOverrides, + pub bumps: ResolvedBumps, + pub main: MainConfig, // Keep entire MainConfig for simplicity +} + +/// Resolved overrides with all templates rendered to values +#[derive(Debug, Clone, Default)] +pub struct ResolvedOverrides { + // VCS overrides (unchanged) + pub tag_version: Option, + pub distance: Option, + pub dirty: bool, + pub no_dirty: bool, + pub clean: bool, + pub current_branch: Option, + pub commit_hash: Option, + + // Version component overrides (resolved from templates) + pub major: Option, + pub minor: Option, + pub patch: Option, + pub epoch: Option, + pub post: Option, + pub dev: Option, + pub pre_release_label: Option, + pub pre_release_num: Option, + pub custom: Option, + + // Schema component overrides (resolved from templates) + pub core: Vec, // Resolved INDEX=VALUE strings + pub extra_core: Vec, + pub build: Vec, +} + +/// Resolved bumps with all templates rendered to values +#[derive(Debug, Clone, Default)] +pub struct ResolvedBumps { + // Field-based bumps (resolved from templates) + pub bump_major: Option>, + pub bump_minor: Option>, + pub bump_patch: Option>, + pub bump_post: Option>, + pub bump_dev: Option>, + pub bump_pre_release_num: Option>, + pub bump_epoch: Option>, + pub bump_pre_release_label: Option, + + // Schema-based bumps (resolved from templates) + pub bump_core: Vec, + pub bump_extra_core: Vec, + pub bump_build: Vec, + + // Context control (unchanged) + pub bump_context: bool, + pub no_bump_context: bool, +} + +impl ResolvedArgs { + /// Resolve all templates in VersionArgs using Zerv snapshot + pub fn resolve(args: &VersionArgs, zerv: &Zerv) -> Result { + let overrides = ResolvedOverrides::resolve(&args.overrides, zerv)?; + let bumps = ResolvedBumps::resolve(&args.bumps, zerv)?; + + Ok(ResolvedArgs { + overrides, + bumps, + main: args.main.clone(), + }) + } +} + +impl ResolvedOverrides { + fn resolve(overrides: &OverridesConfig, zerv: &Zerv) -> Result { + Ok(ResolvedOverrides { + // VCS overrides (copy as-is) + tag_version: overrides.tag_version.clone(), + distance: overrides.distance, + dirty: overrides.dirty, + no_dirty: overrides.no_dirty, + clean: overrides.clean, + current_branch: overrides.current_branch.clone(), + commit_hash: overrides.commit_hash.clone(), + + // Version component overrides (resolve templates) + major: Self::resolve_template(&overrides.major, zerv)?, + minor: Self::resolve_template(&overrides.minor, zerv)?, + patch: Self::resolve_template(&overrides.patch, zerv)?, + epoch: Self::resolve_template(&overrides.epoch, zerv)?, + post: Self::resolve_template(&overrides.post, zerv)?, + dev: Self::resolve_template(&overrides.dev, zerv)?, + pre_release_label: overrides.pre_release_label.clone(), + pre_release_num: Self::resolve_template(&overrides.pre_release_num, zerv)?, + custom: overrides.custom.clone(), + + // Schema component overrides (resolve templates) + core: Self::resolve_template_strings(&overrides.core, zerv)?, + extra_core: Self::resolve_template_strings(&overrides.extra_core, zerv)?, + build: Self::resolve_template_strings(&overrides.build, zerv)?, + }) + } + + fn resolve_template( + template: &Option>, + zerv: &Zerv, + ) -> Result, ZervError> + where + T: FromStr + Clone, + T::Err: Display, + { + match template { + Some(t) => Ok(Some(t.resolve(zerv)?)), + None => Ok(None), + } + } + + fn resolve_template_strings( + templates: &[Template], + zerv: &Zerv, + ) -> Result, ZervError> { + templates + .iter() + .map(|template| template.resolve(zerv)) + .collect() + } + + /// Get the dirty override state (None = use VCS, Some(bool) = override) + pub fn dirty_override(&self) -> Option { + match (self.dirty, self.no_dirty) { + (true, false) => Some(true), // --dirty + (false, true) => Some(false), // --no-dirty + (false, false) => None, // neither (use VCS) + (true, true) => unreachable!(), // Should be caught by validation + } + } +} + +impl ResolvedBumps { + fn resolve(bumps: &BumpsConfig, zerv: &Zerv) -> Result { + Ok(ResolvedBumps { + // Field-based bumps (resolve templates) + bump_major: Self::resolve_bump(&bumps.bump_major, zerv)?, + bump_minor: Self::resolve_bump(&bumps.bump_minor, zerv)?, + bump_patch: Self::resolve_bump(&bumps.bump_patch, zerv)?, + bump_post: Self::resolve_bump(&bumps.bump_post, zerv)?, + bump_dev: Self::resolve_bump(&bumps.bump_dev, zerv)?, + bump_pre_release_num: Self::resolve_bump(&bumps.bump_pre_release_num, zerv)?, + bump_epoch: Self::resolve_bump(&bumps.bump_epoch, zerv)?, + bump_pre_release_label: bumps.bump_pre_release_label.clone(), + + // Schema-based bumps (resolve templates) + bump_core: Self::resolve_template_strings(&bumps.bump_core, zerv)?, + bump_extra_core: Self::resolve_template_strings(&bumps.bump_extra_core, zerv)?, + bump_build: Self::resolve_template_strings(&bumps.bump_build, zerv)?, + + // Context control (copy as-is) + bump_context: bumps.bump_context, + no_bump_context: bumps.no_bump_context, + }) + } + + fn resolve_bump( + bump: &Option>>, + zerv: &Zerv, + ) -> Result>, ZervError> { + match bump { + Some(Some(template)) => Ok(Some(Some(template.resolve(zerv)?))), + Some(None) => Ok(Some(None)), + None => Ok(None), + } + } + + fn resolve_template_strings( + templates: &[Template], + zerv: &Zerv, + ) -> Result, ZervError> { + templates + .iter() + .map(|template| template.resolve(zerv)) + .collect() + } +} diff --git a/src/cli/version/args/tests/bumps_tests.rs b/src/cli/version/args/tests/bumps_tests.rs index 4da2bc6..f768956 100644 --- a/src/cli/version/args/tests/bumps_tests.rs +++ b/src/cli/version/args/tests/bumps_tests.rs @@ -42,9 +42,9 @@ fn test_bumps_config_with_values() { ]) .unwrap(); - assert_eq!(config.bump_major, Some(Some(1))); - assert_eq!(config.bump_minor, Some(Some(2))); - assert_eq!(config.bump_patch, Some(Some(3))); + assert_eq!(config.bump_major, Some(Some(1.into()))); + assert_eq!(config.bump_minor, Some(Some(2.into()))); + assert_eq!(config.bump_patch, Some(Some(3.into()))); assert_eq!(config.bump_pre_release_label, Some("alpha".to_string())); assert!(config.bump_context); assert!(!config.no_bump_context); @@ -67,9 +67,9 @@ fn test_bumps_config_schema_based() { ]) .unwrap(); - assert_eq!(config.bump_core, vec!["0=1", "2=3"]); - assert_eq!(config.bump_extra_core, vec!["1=5"]); - assert_eq!(config.bump_build, vec!["0=10", "1=20"]); + assert_eq!(config.bump_core, vec!["0=1".into(), "2=3".into()]); + assert_eq!(config.bump_extra_core, vec!["1=5".into()]); + assert_eq!(config.bump_build, vec!["0=10".into(), "1=20".into()]); } #[test] @@ -123,9 +123,9 @@ fn test_validate_bumps_schema_bump_args_valid() { .unwrap(); assert!(Validation::validate_bumps(&config).is_ok()); - assert_eq!(config.bump_core, vec!["0=1", "2=3"]); - assert_eq!(config.bump_extra_core, vec!["1=5"]); - assert_eq!(config.bump_build, vec!["0=10", "1=20"]); + assert_eq!(config.bump_core, vec!["0=1".into(), "2=3".into()]); + assert_eq!(config.bump_extra_core, vec!["1=5".into()]); + assert_eq!(config.bump_build, vec!["0=10".into(), "1=20".into()]); } #[test] @@ -133,7 +133,7 @@ fn test_validate_bumps_schema_bump_args_invalid_odd_count() { // Test invalid schema bump arguments (odd number of arguments) // We need to manually create the config with odd count since clap validates pairs let config = BumpsConfig { - bump_core: vec!["0=1".to_string(), "2".to_string()], // Odd count: 2 elements (one without value) + bump_core: vec!["0=1".into(), "2".into()], // Odd count: 2 elements (one without value) ..Default::default() }; let result = Validation::validate_bumps(&config); @@ -209,12 +209,12 @@ fn test_resolve_bump_defaults() { assert!(Validation::resolve_bump_defaults(&mut config).is_ok()); - // All Some(None) should become Some(Some(1)) - assert_eq!(config.bump_major, Some(Some(1))); - assert_eq!(config.bump_minor, Some(Some(1))); - assert_eq!(config.bump_patch, Some(Some(1))); - assert_eq!(config.bump_post, Some(Some(1))); - assert_eq!(config.bump_dev, Some(Some(1))); - assert_eq!(config.bump_pre_release_num, Some(Some(1))); - assert_eq!(config.bump_epoch, Some(Some(1))); + // All Some(None) should become Some(Some(1.into())) + assert_eq!(config.bump_major, Some(Some(1.into()))); + assert_eq!(config.bump_minor, Some(Some(1.into()))); + assert_eq!(config.bump_patch, Some(Some(1.into()))); + assert_eq!(config.bump_post, Some(Some(1.into()))); + assert_eq!(config.bump_dev, Some(Some(1.into()))); + assert_eq!(config.bump_pre_release_num, Some(Some(1.into()))); + assert_eq!(config.bump_epoch, Some(Some(1.into()))); } diff --git a/src/cli/version/args/tests/overrides_tests.rs b/src/cli/version/args/tests/overrides_tests.rs index 83c2f6d..398eec1 100644 --- a/src/cli/version/args/tests/overrides_tests.rs +++ b/src/cli/version/args/tests/overrides_tests.rs @@ -60,11 +60,11 @@ fn test_overrides_config_with_values() { assert!(!config.clean); assert_eq!(config.current_branch, Some("feature/test".to_string())); assert_eq!(config.commit_hash, Some("abc123".to_string())); - assert_eq!(config.major, Some(2)); - assert_eq!(config.minor, Some(1)); - assert_eq!(config.patch, Some(0)); + assert_eq!(config.major, Some(2.into())); + assert_eq!(config.minor, Some(1.into())); + assert_eq!(config.patch, Some(0.into())); assert_eq!(config.pre_release_label, Some("alpha".to_string())); - assert_eq!(config.pre_release_num, Some(1)); + assert_eq!(config.pre_release_num, Some(1.into())); } #[test] diff --git a/src/cli/version/args/tests/resolved_tests.rs b/src/cli/version/args/tests/resolved_tests.rs new file mode 100644 index 0000000..8ce4be7 --- /dev/null +++ b/src/cli/version/args/tests/resolved_tests.rs @@ -0,0 +1,214 @@ +use rstest::rstest; + +use super::super::*; +use crate::test_utils::version_args::VersionArgsFixture; +use crate::test_utils::zerv::ZervFixture; + +#[rstest] +#[case(2, 1, 0, 1, 2)] +#[case(5, 3, 1, 2, 4)] +fn test_resolved_args_basic_resolution( + #[case] major: u32, + #[case] minor: u32, + #[case] patch: u32, + #[case] bump_major: u32, + #[case] bump_minor: u32, +) { + let args = VersionArgsFixture::new() + .with_major(major) + .with_minor(minor) + .with_patch(patch) + .with_bump_major(bump_major) + .with_bump_minor(bump_minor) + .build(); + + let zerv = ZervFixture::new().with_version(1, 0, 0).build(); + let resolved = ResolvedArgs::resolve(&args, &zerv).unwrap(); + + assert_eq!(resolved.overrides.major, Some(major)); + assert_eq!(resolved.overrides.minor, Some(minor)); + assert_eq!(resolved.overrides.patch, Some(patch)); + assert_eq!(resolved.bumps.bump_major, Some(Some(bump_major))); + assert_eq!(resolved.bumps.bump_minor, Some(Some(bump_minor))); +} + +#[rstest] +#[case(2, 1, 3)] +#[case(5, 4, 2)] +fn test_resolved_args_template_resolution( + #[case] major: u64, + #[case] minor: u64, + #[case] patch: u64, +) { + let mut args = VersionArgsFixture::new().build(); + args.overrides.major = Some("{{major}}".into()); + args.overrides.minor = Some("{{minor}}".into()); + args.overrides.patch = Some("{{patch}}".into()); + args.bumps.bump_major = Some(Some("{{major}}".into())); + args.bumps.bump_minor = Some(Some("{{minor}}".into())); + + let zerv = ZervFixture::new().with_version(major, minor, patch).build(); + let resolved = ResolvedArgs::resolve(&args, &zerv).unwrap(); + + assert_eq!(resolved.overrides.major, Some(major as u32)); + assert_eq!(resolved.overrides.minor, Some(minor as u32)); + assert_eq!(resolved.overrides.patch, Some(patch as u32)); + assert_eq!(resolved.bumps.bump_major, Some(Some(major as u32))); + assert_eq!(resolved.bumps.bump_minor, Some(Some(minor as u32))); +} + +#[test] +fn test_resolved_overrides_vcs_fields() { + let args = VersionArgsFixture::new() + .with_tag_version("v1.0.0") + .with_distance(5) + .with_dirty(true) + .with_current_branch("main") + .with_commit_hash("abc123") + .build(); + + let zerv = ZervFixture::new().build(); + let resolved = ResolvedArgs::resolve(&args, &zerv).unwrap(); + + assert_eq!(resolved.overrides.tag_version, Some("v1.0.0".to_string())); + assert_eq!(resolved.overrides.distance, Some(5)); + assert!(resolved.overrides.dirty); + assert_eq!(resolved.overrides.current_branch, Some("main".to_string())); + assert_eq!(resolved.overrides.commit_hash, Some("abc123".to_string())); +} + +#[rstest] +#[case(true, false, Some(true))] +#[case(false, true, Some(false))] +#[case(false, false, None)] +fn test_resolved_overrides_dirty_override( + #[case] dirty: bool, + #[case] no_dirty: bool, + #[case] expected: Option, +) { + let overrides = ResolvedOverrides { + dirty, + no_dirty, + ..Default::default() + }; + assert_eq!(overrides.dirty_override(), expected); +} + +#[test] +fn test_resolved_overrides_schema_fields() { + let mut args = VersionArgsFixture::new().build(); + args.overrides.core = vec!["0=2".into(), "1={{minor}}".into()]; + args.overrides.extra_core = vec!["0=5".into()]; + args.overrides.build = vec!["0=build".into(), "1={{patch}}".into()]; + + let zerv = ZervFixture::new().with_version(1, 2, 3).build(); + let resolved = ResolvedArgs::resolve(&args, &zerv).unwrap(); + + assert_eq!(resolved.overrides.core, vec!["0=2", "1=2"]); + assert_eq!(resolved.overrides.extra_core, vec!["0=5"]); + assert_eq!(resolved.overrides.build, vec!["0=build", "1=3"]); +} + +#[test] +fn test_resolved_bumps_field_based() { + let mut args = VersionArgsFixture::new() + .with_bump_major(1) + .with_bump_pre_release_label("alpha") + .build(); + args.bumps.bump_minor = Some(None); + args.bumps.bump_patch = Some(Some("{{major}}".into())); + + let zerv = ZervFixture::new().with_version(2, 1, 0).build(); + let resolved = ResolvedArgs::resolve(&args, &zerv).unwrap(); + + assert_eq!(resolved.bumps.bump_major, Some(Some(1))); + assert_eq!(resolved.bumps.bump_minor, Some(None)); + assert_eq!(resolved.bumps.bump_patch, Some(Some(2))); + assert_eq!( + resolved.bumps.bump_pre_release_label, + Some("alpha".to_string()) + ); +} + +#[test] +fn test_resolved_bumps_schema_based() { + let mut args = VersionArgsFixture::new().build(); + args.bumps.bump_core = vec!["0=1".into(), "1={{minor}}".into()]; + args.bumps.bump_extra_core = vec!["0={{patch}}".into()]; + args.bumps.bump_build = vec!["0=test".into()]; + + let zerv = ZervFixture::new().with_version(1, 2, 3).build(); + let resolved = ResolvedArgs::resolve(&args, &zerv).unwrap(); + + assert_eq!(resolved.bumps.bump_core, vec!["0=1", "1=2"]); + assert_eq!(resolved.bumps.bump_extra_core, vec!["0=3"]); + assert_eq!(resolved.bumps.bump_build, vec!["0=test"]); +} + +#[rstest] +#[case(true, false)] +#[case(false, true)] +fn test_resolved_bumps_context_control(#[case] bump_context: bool, #[case] no_bump_context: bool) { + let args = VersionArgsFixture::new() + .with_bump_context(bump_context) + .with_no_bump_context(no_bump_context) + .build(); + + let zerv = ZervFixture::new().build(); + let resolved = ResolvedArgs::resolve(&args, &zerv).unwrap(); + + assert_eq!(resolved.bumps.bump_context, bump_context); + assert_eq!(resolved.bumps.no_bump_context, no_bump_context); +} + +#[test] +fn test_resolved_args_empty_defaults() { + let args = VersionArgsFixture::new().build(); + let zerv = ZervFixture::new().build(); + let resolved = ResolvedArgs::resolve(&args, &zerv).unwrap(); + + assert!(resolved.overrides.major.is_none()); + assert!(resolved.overrides.minor.is_none()); + assert!(resolved.overrides.patch.is_none()); + assert!(resolved.overrides.core.is_empty()); + assert!(resolved.overrides.extra_core.is_empty()); + assert!(resolved.overrides.build.is_empty()); + assert!(resolved.bumps.bump_major.is_none()); + assert!(resolved.bumps.bump_minor.is_none()); + assert!(resolved.bumps.bump_patch.is_none()); + assert!(resolved.bumps.bump_core.is_empty()); + assert!(resolved.bumps.bump_extra_core.is_empty()); + assert!(resolved.bumps.bump_build.is_empty()); + assert!(!resolved.bumps.bump_context); + assert!(!resolved.bumps.no_bump_context); +} + +#[rstest] +#[case(5, 1, 2, 3, 4)] +#[case(10, 2, 1, 5, 7)] +fn test_resolved_args_mixed_templates_and_values( + #[case] override_major: u32, + #[case] bump_major: u32, + #[case] zerv_major: u64, + #[case] zerv_minor: u64, + #[case] zerv_patch: u64, +) { + let mut args = VersionArgsFixture::new() + .with_major(override_major) + .with_bump_major(bump_major) + .build(); + args.overrides.minor = Some("{{major}}".into()); + args.overrides.patch = Some("{{minor}}".into()); + args.bumps.bump_minor = Some(Some("{{patch}}".into())); + + let zerv = ZervFixture::new() + .with_version(zerv_major, zerv_minor, zerv_patch) + .build(); + let resolved = ResolvedArgs::resolve(&args, &zerv).unwrap(); + + assert_eq!(resolved.overrides.major, Some(override_major)); + assert_eq!(resolved.overrides.minor, Some(zerv_major as u32)); + assert_eq!(resolved.overrides.patch, Some(zerv_minor as u32)); + assert_eq!(resolved.bumps.bump_major, Some(Some(bump_major))); + assert_eq!(resolved.bumps.bump_minor, Some(Some(zerv_patch as u32))); +} diff --git a/src/cli/version/args/tests/validation_tests.rs b/src/cli/version/args/tests/validation_tests.rs index f74fc01..ff6f8b2 100644 --- a/src/cli/version/args/tests/validation_tests.rs +++ b/src/cli/version/args/tests/validation_tests.rs @@ -6,21 +6,21 @@ fn assert_invalid_input_rejected(invalid_input: &str, section: &str, expected_ar let mut args = match section { "core" => VersionArgs { bumps: BumpsConfig { - bump_core: vec![invalid_input.to_string()], + bump_core: vec![invalid_input.to_string().into()], ..Default::default() }, ..Default::default() }, "extra_core" => VersionArgs { bumps: BumpsConfig { - bump_extra_core: vec![invalid_input.to_string()], + bump_extra_core: vec![invalid_input.to_string().into()], ..Default::default() }, ..Default::default() }, "build" => VersionArgs { bumps: BumpsConfig { - bump_build: vec![invalid_input.to_string()], + bump_build: vec![invalid_input.to_string().into()], ..Default::default() }, ..Default::default() @@ -77,7 +77,7 @@ fn test_validate_schema_bump_args_invalid_format() { // Test invalid schema bump argument formats let mut args = VersionArgs { bumps: BumpsConfig { - bump_core: vec!["invalid_format".to_string()], + bump_core: vec!["invalid_format".into()], ..Default::default() }, ..Default::default() @@ -98,9 +98,9 @@ fn test_validate_schema_bump_args_valid_formats() { // Test valid schema bump argument formats let mut args = VersionArgs { bumps: BumpsConfig { - bump_core: vec!["0".to_string(), "1=5".to_string(), "-1=3".to_string()], - bump_extra_core: vec!["0=release".to_string()], - bump_build: vec!["-1".to_string()], + bump_core: vec!["0".into(), "1=5".into(), "-1=3".into()], + bump_extra_core: vec!["0=release".into()], + bump_build: vec!["-1".into()], ..Default::default() }, ..Default::default() @@ -133,9 +133,9 @@ fn test_validate_schema_bump_args_valid() { // Test valid schema bump arguments let mut args = VersionArgs { bumps: BumpsConfig { - bump_core: vec!["0=1".to_string(), "2=3".to_string()], - bump_extra_core: vec!["1=5".to_string()], - bump_build: vec!["0=release".to_string()], + bump_core: vec!["0=1".into(), "2=3".into()], + bump_extra_core: vec!["1=5".into()], + bump_build: vec!["0=release".into()], ..Default::default() }, ..Default::default() @@ -149,7 +149,7 @@ fn test_validate_schema_bump_args_invalid_odd_count() { // Test that single values (without =) are now valid (default to value 1) let mut args = VersionArgs { bumps: BumpsConfig { - bump_core: vec!["0=1".to_string(), "2".to_string()], + bump_core: vec!["0=1".into(), "2".into()], ..Default::default() }, ..Default::default() diff --git a/src/cli/version/args/validation.rs b/src/cli/version/args/validation.rs index 7746c6b..5e8141c 100644 --- a/src/cli/version/args/validation.rs +++ b/src/cli/version/args/validation.rs @@ -3,6 +3,7 @@ use super::{ MainConfig, OverridesConfig, }; +use crate::cli::utils::template::Template; use crate::error::ZervError; /// Validation methods for argument combinations @@ -108,39 +109,39 @@ impl Validation { /// Resolve default bump values /// If a bump option is provided without a value, set it to 1 (the default) pub fn resolve_bump_defaults(bumps: &mut BumpsConfig) -> Result<(), ZervError> { - // Resolve bump_major: Some(None) -> Some(Some(1)) + // Resolve bump_major: Some(None) -> Some(Some(Template::Value(1))) if let Some(None) = bumps.bump_major { - bumps.bump_major = Some(Some(1)); + bumps.bump_major = Some(Some(Template::Value(1))); } - // Resolve bump_minor: Some(None) -> Some(Some(1)) + // Resolve bump_minor: Some(None) -> Some(Some(Template::Value(1))) if let Some(None) = bumps.bump_minor { - bumps.bump_minor = Some(Some(1)); + bumps.bump_minor = Some(Some(Template::Value(1))); } - // Resolve bump_patch: Some(None) -> Some(Some(1)) + // Resolve bump_patch: Some(None) -> Some(Some(Template::Value(1))) if let Some(None) = bumps.bump_patch { - bumps.bump_patch = Some(Some(1)); + bumps.bump_patch = Some(Some(Template::Value(1))); } - // Resolve bump_post: Some(None) -> Some(Some(1)) + // Resolve bump_post: Some(None) -> Some(Some(Template::Value(1))) if let Some(None) = bumps.bump_post { - bumps.bump_post = Some(Some(1)); + bumps.bump_post = Some(Some(Template::Value(1))); } - // Resolve bump_dev: Some(None) -> Some(Some(1)) + // Resolve bump_dev: Some(None) -> Some(Some(Template::Value(1))) if let Some(None) = bumps.bump_dev { - bumps.bump_dev = Some(Some(1)); + bumps.bump_dev = Some(Some(Template::Value(1))); } - // Resolve bump_pre_release_num: Some(None) -> Some(Some(1)) + // Resolve bump_pre_release_num: Some(None) -> Some(Some(Template::Value(1))) if let Some(None) = bumps.bump_pre_release_num { - bumps.bump_pre_release_num = Some(Some(1)); + bumps.bump_pre_release_num = Some(Some(Template::Value(1))); } - // Resolve bump_epoch: Some(None) -> Some(Some(1)) + // Resolve bump_epoch: Some(None) -> Some(Some(Template::Value(1))) if let Some(None) = bumps.bump_epoch { - bumps.bump_epoch = Some(Some(1)); + bumps.bump_epoch = Some(Some(Template::Value(1))); } Ok(()) @@ -169,8 +170,13 @@ impl Validation { } /// Validate a single bump section's arguments - fn validate_bump_section(specs: &[String], arg_name: &str) -> Result<(), ZervError> { - for spec in specs { + fn validate_bump_section(specs: &[Template], arg_name: &str) -> Result<(), ZervError> { + for template in specs { + // For validation, we only check the string format, not template resolution + let spec = match template { + Template::Value(s) => s, + Template::Template(s) => s, // Template strings are validated at resolution time + }; if !Self::is_valid_bump_spec(spec) { return Err(ZervError::InvalidArgument(format!( "{arg_name} argument '{spec}' must be in format 'index[=value]'" diff --git a/src/cli/version/pipeline.rs b/src/cli/version/pipeline.rs index debe8a4..5dce919 100644 --- a/src/cli/version/pipeline.rs +++ b/src/cli/version/pipeline.rs @@ -25,12 +25,12 @@ pub fn run_version_pipeline(mut args: VersionArgs) -> Result // 3. Convert to Zerv (applies overrides internally) let zerv_object = zerv_draft.to_zerv(&args)?; - // 4. Apply output formatting with enhanced options + // 4. Apply output formatting with template resolution let output = OutputFormatter::format_output( &zerv_object, &args.main.output_format, args.main.output_prefix.as_deref(), - args.main.output_template.as_deref(), + &args.main.output_template, )?; Ok(output) diff --git a/src/cli/version/zerv_draft.rs b/src/cli/version/zerv_draft.rs index 2f17161..e26833f 100644 --- a/src/cli/version/zerv_draft.rs +++ b/src/cli/version/zerv_draft.rs @@ -1,4 +1,7 @@ -use crate::cli::version::args::VersionArgs; +use crate::cli::version::args::{ + ResolvedArgs, + VersionArgs, +}; use crate::error::ZervError; use crate::schema::{ get_preset_schema, @@ -31,8 +34,11 @@ impl ZervDraft { let (schema_name, schema_ron) = args.resolve_schema(); let mut zerv = self.create_zerv_version(schema_name, schema_ron)?; + // Resolve templates using the current Zerv state + let resolved_args = ResolvedArgs::resolve(args, &zerv)?; + // Apply component processing (bumps with reset logic) - zerv.apply_component_processing(args)?; + zerv.apply_component_processing(&resolved_args)?; zerv.normalize(); Ok(zerv) diff --git a/src/test_utils/version_args.rs b/src/test_utils/version_args.rs index 39a00a4..ad76308 100644 --- a/src/test_utils/version_args.rs +++ b/src/test_utils/version_args.rs @@ -1,3 +1,4 @@ +use crate::cli::utils::template::Template; use crate::cli::version::args::VersionArgs; use crate::test_utils::types::{ BumpType, @@ -60,7 +61,7 @@ impl VersionArgsFixture { /// Set output template pub fn with_output_template(mut self, template: &str) -> Self { - self.args.main.output_template = Some(template.to_string()); + self.args.main.output_template = Some(Template::Value(template.to_string())); self } @@ -118,13 +119,13 @@ impl VersionArgsFixture { /// Set post value pub fn with_post(mut self, post: u32) -> Self { - self.args.overrides.post = Some(post); + self.args.overrides.post = Some(Template::Value(post)); self } /// Set dev value pub fn with_dev(mut self, dev: u32) -> Self { - self.args.overrides.dev = Some(dev); + self.args.overrides.dev = Some(Template::Value(dev)); self } @@ -136,31 +137,31 @@ impl VersionArgsFixture { /// Set pre-release number pub fn with_pre_release_num(mut self, num: u32) -> Self { - self.args.overrides.pre_release_num = Some(num); + self.args.overrides.pre_release_num = Some(num.into()); self } /// Set epoch pub fn with_epoch(mut self, epoch: u32) -> Self { - self.args.overrides.epoch = Some(epoch); + self.args.overrides.epoch = Some(epoch.into()); self } /// Set major version pub fn with_major(mut self, major: u32) -> Self { - self.args.overrides.major = Some(major); + self.args.overrides.major = Some(major.into()); self } /// Set minor version pub fn with_minor(mut self, minor: u32) -> Self { - self.args.overrides.minor = Some(minor); + self.args.overrides.minor = Some(minor.into()); self } /// Set patch version pub fn with_patch(mut self, patch: u32) -> Self { - self.args.overrides.patch = Some(patch); + self.args.overrides.patch = Some(patch.into()); self } @@ -174,43 +175,43 @@ impl VersionArgsFixture { /// Set bump major pub fn with_bump_major(mut self, increment: u32) -> Self { - self.args.bumps.bump_major = Some(Some(increment)); + self.args.bumps.bump_major = Some(Some(increment.into())); self } /// Set bump minor pub fn with_bump_minor(mut self, increment: u32) -> Self { - self.args.bumps.bump_minor = Some(Some(increment)); + self.args.bumps.bump_minor = Some(Some(increment.into())); self } /// Set bump patch pub fn with_bump_patch(mut self, increment: u32) -> Self { - self.args.bumps.bump_patch = Some(Some(increment)); + self.args.bumps.bump_patch = Some(Some(increment.into())); self } /// Set bump post pub fn with_bump_post(mut self, increment: u32) -> Self { - self.args.bumps.bump_post = Some(Some(increment)); + self.args.bumps.bump_post = Some(Some(increment.into())); self } /// Set bump dev pub fn with_bump_dev(mut self, increment: u32) -> Self { - self.args.bumps.bump_dev = Some(Some(increment)); + self.args.bumps.bump_dev = Some(Some(increment.into())); self } /// Set bump pre-release number pub fn with_bump_pre_release_num(mut self, increment: u32) -> Self { - self.args.bumps.bump_pre_release_num = Some(Some(increment)); + self.args.bumps.bump_pre_release_num = Some(Some(increment.into())); self } /// Set bump epoch pub fn with_bump_epoch(mut self, increment: u32) -> Self { - self.args.bumps.bump_epoch = Some(Some(increment)); + self.args.bumps.bump_epoch = Some(Some(increment.into())); self } @@ -239,23 +240,25 @@ impl VersionArgsFixture { for bump_type in bumps { match bump_type { BumpType::Major(increment) => { - self.args.bumps.bump_major = Some(Some(increment as u32)) + self.args.bumps.bump_major = Some(Some((increment as u32).into())) } BumpType::Minor(increment) => { - self.args.bumps.bump_minor = Some(Some(increment as u32)) + self.args.bumps.bump_minor = Some(Some((increment as u32).into())) } BumpType::Patch(increment) => { - self.args.bumps.bump_patch = Some(Some(increment as u32)) + self.args.bumps.bump_patch = Some(Some((increment as u32).into())) } BumpType::Post(increment) => { - self.args.bumps.bump_post = Some(Some(increment as u32)) + self.args.bumps.bump_post = Some(Some((increment as u32).into())) + } + BumpType::Dev(increment) => { + self.args.bumps.bump_dev = Some(Some((increment as u32).into())) } - BumpType::Dev(increment) => self.args.bumps.bump_dev = Some(Some(increment as u32)), BumpType::Epoch(increment) => { - self.args.bumps.bump_epoch = Some(Some(increment as u32)) + self.args.bumps.bump_epoch = Some(Some((increment as u32).into())) } BumpType::PreReleaseNum(increment) => { - self.args.bumps.bump_pre_release_num = Some(Some(increment as u32)) + self.args.bumps.bump_pre_release_num = Some(Some((increment as u32).into())) } BumpType::PreReleaseLabel(_) => { // For now, we don't handle pre-release label bumps in test fixtures @@ -273,13 +276,13 @@ impl VersionArgsFixture { }; match section.as_str() { "core" => { - self.args.bumps.bump_core.push(spec); + self.args.bumps.bump_core.push(spec.into()); } "extra_core" => { - self.args.bumps.bump_extra_core.push(spec); + self.args.bumps.bump_extra_core.push(spec.into()); } "build" => { - self.args.bumps.bump_build.push(spec); + self.args.bumps.bump_build.push(spec.into()); } _ => { // Unknown section - ignore for now @@ -304,16 +307,18 @@ impl VersionArgsFixture { self.args.overrides.current_branch = Some(branch) } OverrideType::CommitHash(hash) => self.args.overrides.commit_hash = Some(hash), - OverrideType::Major(major) => self.args.overrides.major = Some(major), - OverrideType::Minor(minor) => self.args.overrides.minor = Some(minor), - OverrideType::Patch(patch) => self.args.overrides.patch = Some(patch), - OverrideType::Post(post) => self.args.overrides.post = Some(post), - OverrideType::Dev(dev) => self.args.overrides.dev = Some(dev), + OverrideType::Major(major) => self.args.overrides.major = Some(major.into()), + OverrideType::Minor(minor) => self.args.overrides.minor = Some(minor.into()), + OverrideType::Patch(patch) => self.args.overrides.patch = Some(patch.into()), + OverrideType::Post(post) => self.args.overrides.post = Some(post.into()), + OverrideType::Dev(dev) => self.args.overrides.dev = Some(dev.into()), OverrideType::PreReleaseLabel(label) => { self.args.overrides.pre_release_label = Some(label) } - OverrideType::PreReleaseNum(num) => self.args.overrides.pre_release_num = Some(num), - OverrideType::Epoch(epoch) => self.args.overrides.epoch = Some(epoch), + OverrideType::PreReleaseNum(num) => { + self.args.overrides.pre_release_num = Some(num.into()) + } + OverrideType::Epoch(epoch) => self.args.overrides.epoch = Some(epoch.into()), } } self @@ -398,13 +403,13 @@ mod tests { .with_bump_pre_release_num(8) .build(); - assert_eq!(args.bumps.bump_major, Some(Some(2))); - assert_eq!(args.bumps.bump_minor, Some(Some(3))); - assert_eq!(args.bumps.bump_patch, Some(Some(4))); - assert_eq!(args.bumps.bump_post, Some(Some(5))); - assert_eq!(args.bumps.bump_dev, Some(Some(6))); - assert_eq!(args.bumps.bump_epoch, Some(Some(7))); - assert_eq!(args.bumps.bump_pre_release_num, Some(Some(8))); + assert_eq!(args.bumps.bump_major, Some(Some(2.into()))); + assert_eq!(args.bumps.bump_minor, Some(Some(3.into()))); + assert_eq!(args.bumps.bump_patch, Some(Some(4.into()))); + assert_eq!(args.bumps.bump_post, Some(Some(5.into()))); + assert_eq!(args.bumps.bump_dev, Some(Some(6.into()))); + assert_eq!(args.bumps.bump_epoch, Some(Some(7.into()))); + assert_eq!(args.bumps.bump_pre_release_num, Some(Some(8.into()))); } #[test] @@ -416,9 +421,9 @@ mod tests { .with_tag_version("v1.0.0") .build(); - assert_eq!(args.bumps.bump_major, Some(Some(2))); - assert_eq!(args.bumps.bump_minor, Some(Some(3))); - assert_eq!(args.bumps.bump_patch, Some(Some(1))); + assert_eq!(args.bumps.bump_major, Some(Some(2.into()))); + assert_eq!(args.bumps.bump_minor, Some(Some(3.into()))); + assert_eq!(args.bumps.bump_patch, Some(Some(1.into()))); assert_eq!(args.overrides.tag_version, Some("v1.0.0".to_string())); } diff --git a/src/version/zerv/bump/mod.rs b/src/version/zerv/bump/mod.rs index d722711..560361b 100644 --- a/src/version/zerv/bump/mod.rs +++ b/src/version/zerv/bump/mod.rs @@ -1,5 +1,5 @@ use super::core::Zerv; -use crate::cli::version::args::VersionArgs; +use crate::cli::version::args::ResolvedArgs; use crate::error::ZervError; pub mod precedence; @@ -12,7 +12,7 @@ pub mod vars_timestamp; use crate::version::zerv::bump::precedence::Precedence; impl Zerv { - pub fn apply_component_processing(&mut self, args: &VersionArgs) -> Result<(), ZervError> { + pub fn apply_component_processing(&mut self, args: &ResolvedArgs) -> Result<(), ZervError> { let precedence_order: Vec = self.schema.precedence_order().iter().cloned().collect(); @@ -99,7 +99,8 @@ mod tests { .build(); let args = VersionArgsFixture::new().with_bump_specs(bumps).build(); - zerv.apply_component_processing(&args).unwrap(); + let resolved_args = crate::cli::version::args::ResolvedArgs::resolve(&args, &zerv).unwrap(); + zerv.apply_component_processing(&resolved_args).unwrap(); let result_version: SemVer = zerv.into(); assert_eq!(result_version.to_string(), expected_version); @@ -135,7 +136,8 @@ mod tests { // Apply context overrides first, then component processing zerv.vars.apply_context_overrides(&args).unwrap(); - zerv.apply_component_processing(&args).unwrap(); + let resolved_args = crate::cli::version::args::ResolvedArgs::resolve(&args, &zerv).unwrap(); + zerv.apply_component_processing(&resolved_args).unwrap(); let result_version: SemVer = zerv.into(); assert_eq!(result_version.to_string(), expected_version); @@ -167,7 +169,8 @@ mod tests { // Apply context overrides first, then component processing zerv.vars.apply_context_overrides(&args).unwrap(); - zerv.apply_component_processing(&args).unwrap(); + let resolved_args = crate::cli::version::args::ResolvedArgs::resolve(&args, &zerv).unwrap(); + zerv.apply_component_processing(&resolved_args).unwrap(); let result_version: SemVer = zerv.into(); assert_eq!(result_version.to_string(), expected_version); @@ -224,7 +227,8 @@ mod tests { let args = VersionArgsFixture::new().with_bump_specs(bumps).build(); - zerv.apply_component_processing(&args).unwrap(); + let resolved_args = crate::cli::version::args::ResolvedArgs::resolve(&args, &zerv).unwrap(); + zerv.apply_component_processing(&resolved_args).unwrap(); let result_version: SemVer = zerv.into(); assert_eq!(result_version.to_string(), expected_version); diff --git a/src/version/zerv/bump/vars_secondary.rs b/src/version/zerv/bump/vars_secondary.rs index 11a0711..4f79f77 100644 --- a/src/version/zerv/bump/vars_secondary.rs +++ b/src/version/zerv/bump/vars_secondary.rs @@ -1,5 +1,5 @@ use super::Zerv; -use crate::cli::version::args::VersionArgs; +use crate::cli::version::args::ResolvedArgs; use crate::error::ZervError; use crate::version::zerv::bump::precedence::Precedence; use crate::version::zerv::core::{ @@ -50,7 +50,7 @@ impl Zerv { Ok(()) } - pub fn process_pre_release_label(&mut self, args: &VersionArgs) -> Result<(), ZervError> { + pub fn process_pre_release_label(&mut self, args: &ResolvedArgs) -> Result<(), ZervError> { // 1. Override step - set absolute value if specified if let Some(ref label) = args.overrides.pre_release_label { let existing_number = self.vars.pre_release.as_ref().and_then(|pr| pr.number); @@ -256,7 +256,10 @@ mod tests { args_fixture = args_fixture.with_bump_pre_release_label(label); } let args = args_fixture.build(); - zerv.process_pre_release_label(&args).unwrap(); + let dummy_zerv = crate::test_utils::zerv::ZervFixture::new().build(); + let resolved_args = + crate::cli::version::args::ResolvedArgs::resolve(&args, &dummy_zerv).unwrap(); + zerv.process_pre_release_label(&resolved_args).unwrap(); let result_version: SemVer = zerv.into(); assert_eq!(result_version.to_string(), expected_version); } @@ -296,7 +299,10 @@ mod tests { let args = VersionArgsFixture::new() .with_bump_pre_release_label("invalid") .build(); - let result = zerv.process_pre_release_label(&args); + let dummy_zerv = crate::test_utils::zerv::ZervFixture::new().build(); + let resolved_args = + crate::cli::version::args::ResolvedArgs::resolve(&args, &dummy_zerv).unwrap(); + let result = zerv.process_pre_release_label(&resolved_args); assert!(result.is_err()); assert!( result diff --git a/src/version/zerv/bump/vars_timestamp.rs b/src/version/zerv/bump/vars_timestamp.rs index 2a6520c..a59771f 100644 --- a/src/version/zerv/bump/vars_timestamp.rs +++ b/src/version/zerv/bump/vars_timestamp.rs @@ -1,9 +1,9 @@ use super::Zerv; -use crate::cli::version::args::VersionArgs; +use crate::cli::version::args::ResolvedArgs; use crate::error::ZervError; impl Zerv { - pub fn process_bumped_timestamp(&mut self, _args: &VersionArgs) -> Result<(), ZervError> { + pub fn process_bumped_timestamp(&mut self, _args: &ResolvedArgs) -> Result<(), ZervError> { if self.vars.dirty == Some(true) { self.vars.bumped_timestamp = Some(chrono::Utc::now().timestamp() as u64); } @@ -25,7 +25,10 @@ mod tests { zerv.vars.bumped_timestamp = Some(old_timestamp); let args = crate::cli::version::args::VersionArgs::default(); - zerv.process_bumped_timestamp(&args).unwrap(); + let dummy_zerv = crate::test_utils::zerv::ZervFixture::new().build(); + let resolved_args = + crate::cli::version::args::ResolvedArgs::resolve(&args, &dummy_zerv).unwrap(); + zerv.process_bumped_timestamp(&resolved_args).unwrap(); // Should update to current timestamp when dirty (uncommitted changes) assert!(zerv.vars.bumped_timestamp.is_some()); @@ -42,7 +45,10 @@ mod tests { zerv.vars.bumped_timestamp = Some(vcs_timestamp); let args = crate::cli::version::args::VersionArgs::default(); - zerv.process_bumped_timestamp(&args).unwrap(); + let dummy_zerv = crate::test_utils::zerv::ZervFixture::new().build(); + let resolved_args = + crate::cli::version::args::ResolvedArgs::resolve(&args, &dummy_zerv).unwrap(); + zerv.process_bumped_timestamp(&resolved_args).unwrap(); // Should keep VCS commit timestamp when clean (represents current commit) assert_eq!(zerv.vars.bumped_timestamp, Some(vcs_timestamp)); @@ -59,7 +65,10 @@ mod tests { zerv.vars.bumped_timestamp = Some(old_timestamp); let args = crate::cli::version::args::VersionArgs::default(); - zerv.process_bumped_timestamp(&args).unwrap(); + let dummy_zerv = crate::test_utils::zerv::ZervFixture::new().build(); + let resolved_args = + crate::cli::version::args::ResolvedArgs::resolve(&args, &dummy_zerv).unwrap(); + zerv.process_bumped_timestamp(&resolved_args).unwrap(); // Should keep existing timestamp when dirty=false (context control forces clean state) assert_eq!(zerv.vars.bumped_timestamp, Some(old_timestamp)); @@ -75,7 +84,10 @@ mod tests { zerv.vars.bumped_timestamp = Some(vcs_timestamp); let args = crate::cli::version::args::VersionArgs::default(); - zerv.process_bumped_timestamp(&args).unwrap(); + let dummy_zerv = crate::test_utils::zerv::ZervFixture::new().build(); + let resolved_args = + crate::cli::version::args::ResolvedArgs::resolve(&args, &dummy_zerv).unwrap(); + zerv.process_bumped_timestamp(&resolved_args).unwrap(); // Should keep existing timestamp when bump context is disabled assert_eq!(zerv.vars.bumped_timestamp, Some(vcs_timestamp)); From b3dd4923dd7f0a06520761b59139924e283b9a19 Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Sat, 18 Oct 2025 18:02:01 +0700 Subject: [PATCH 04/13] feat: resolve flaky tests --- src/test_utils/git/docker.rs | 68 ++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/src/test_utils/git/docker.rs b/src/test_utils/git/docker.rs index 38d603b..95b4f1f 100644 --- a/src/test_utils/git/docker.rs +++ b/src/test_utils/git/docker.rs @@ -162,23 +162,69 @@ impl DockerGit { .cloned() } - /// Helper method to execute a Docker command with proper error handling + /// Helper method to execute a Docker command with proper error handling and retry logic fn execute_docker_command(&self, script: &str, operation_name: &str) -> io::Result { - let container_id = self.get_container_id()?; + self.execute_docker_command_with_retry(script, operation_name, 5) + } + /// Execute a single Docker command without retry logic + fn execute_single_docker_command( + &self, + container_id: &str, + script: &str, + ) -> io::Result<(bool, String, String)> { let output = Command::new("docker") - .args(["exec", &container_id, "sh", "-c", script]) + .args(["exec", container_id, "sh", "-c", script]) .output()?; - if !output.status.success() { - return Err(io::Error::other(format!( - "Docker {} failed: {}", - operation_name, - String::from_utf8_lossy(&output.stderr) - ))); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + Ok((output.status.success(), stdout, stderr)) + } + + /// Execute Docker command with retry logic for flaky operations + fn execute_docker_command_with_retry( + &self, + script: &str, + operation_name: &str, + max_attempts: usize, + ) -> io::Result { + let container_id = self.get_container_id()?; + let mut last_error = None; + + for attempt in 1..=max_attempts { + let (success, stdout, stderr) = + self.execute_single_docker_command(&container_id, script)?; + + if success { + return Ok(stdout); + } + + last_error = Some(format!( + "Docker {operation_name} failed (attempt {attempt}/{max_attempts}): {stderr}" + )); + + // Only retry for specific git reference errors that are likely transient + if stderr.contains("cannot update ref") || stderr.contains("nonexistent object") { + if attempt < max_attempts { + println!( + "🔄 RETRY: {} (attempt {}/{}) - {}", + operation_name, + attempt, + max_attempts, + stderr.trim() + ); + std::thread::sleep(std::time::Duration::from_millis(100 * attempt as u64)); + continue; + } + } else { + // For other errors, fail immediately + break; + } } - Ok(String::from_utf8_lossy(&output.stdout).to_string()) + Err(io::Error::other(last_error.unwrap())) } fn run_docker_command(&self, test_dir: &TestDir, script: &str) -> io::Result { @@ -301,7 +347,7 @@ impl GitOperations for DockerGit { "# ); - self.execute_docker_command(&tag_script, "tag creation")?; + self.execute_docker_command_with_retry(&tag_script, "tag creation", 3)?; Ok(()) } From e6e747b17d6c5bb1483fc9e18ef52a17db4cf383 Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Sat, 18 Oct 2025 20:45:49 +0700 Subject: [PATCH 05/13] docs: update plan for integration tests --- ...26-zerv-cli-comprehensive-documentation.md | 648 ++++++++++++++++++ .dev/27-integration-tests-revamp-plan.md | 217 ++++++ 2 files changed, 865 insertions(+) create mode 100644 .dev/26-zerv-cli-comprehensive-documentation.md create mode 100644 .dev/27-integration-tests-revamp-plan.md diff --git a/.dev/26-zerv-cli-comprehensive-documentation.md b/.dev/26-zerv-cli-comprehensive-documentation.md new file mode 100644 index 0000000..7ef9000 --- /dev/null +++ b/.dev/26-zerv-cli-comprehensive-documentation.md @@ -0,0 +1,648 @@ +# Zerv CLI - Comprehensive Documentation + +## Overview + +Zerv is a dynamic versioning tool that generates version strings from version control system (VCS) data using configurable schemas. It supports multiple input sources, output formats, and advanced override capabilities for CI/CD workflows. + +## Installation + +### Quick Install (Recommended) + +```bash +# Install latest version +curl -sSL https://raw.githubusercontent.com/wislertt/zerv/main/scripts/install.sh | bash + +# Install specific version +curl -sSL https://raw.githubusercontent.com/wislertt/zerv/main/scripts/install.sh | bash -s v0.4.3 + +# Or using environment variable +curl -sSL https://raw.githubusercontent.com/wislertt/zerv/main/scripts/install.sh | ZERV_VERSION=v0.4.3 bash +``` + +### Manual Download + +Download pre-built binaries from [GitHub Releases](https://github.com/wislertt/zerv/releases) + +### From Source (Cargo) + +```bash +cargo install zerv +``` + +## Commands + +Zerv provides two main commands: + +- `zerv version` - Generate version strings from VCS data +- `zerv check` - Validate version string format compliance + +## Command: `zerv version` + +Generate version strings from version control system data using configurable schemas. + +### Basic Syntax + +```bash +zerv version [OPTIONS] +``` + +### Input Sources + +#### Git Repository (Default) + +```bash +# Extract version data from git repository +zerv version --source git # default +zerv version # same as above +``` + +#### Standard Input + +```bash +# Read Zerv RON format from stdin for piping workflows +echo "..." | zerv version --source stdin +``` + +### Output Formats + +#### Semantic Versioning (Default) + +```bash +zerv version --output-format semver # default +zerv version # same as above +# Output: 0.7.72-post.4+dev.b3dd492 +``` + +#### Python PEP440 + +```bash +zerv version --output-format pep440 +# Output: 0.7.72.post4+dev.b3dd492 +``` + +#### Zerv RON Format (For Piping) + +```bash +zerv version --output-format zerv +# Output: Complete RON structure with schema and variables +``` + +### Schema System + +#### Built-in Schema Presets + +**Standard Schema (Default)** + +```bash +zerv version --schema zerv-standard # default +zerv version # same as above +# Output: 0.7.72-post.4+dev.b3dd492 +``` + +**Calendar Versioning Schema** + +```bash +zerv version --schema zerv-calver +# Output: 2025.10.16-72.post.4+dev.b3dd492 +``` + +#### Custom RON Schema + +```bash +zerv version --schema-ron '(core: [var(Major), var(Minor), var(Patch)])' +``` + +### VCS Override Options + +Override detected VCS values for testing and simulation: + +#### Tag Version Override + +```bash +# Override detected tag version +zerv version --tag-version v2.0.0 +zerv version --tag-version 1.5.0-beta.1 +``` + +#### Distance Override + +```bash +# Override distance from tag (number of commits since tag) +zerv version --distance 5 +``` + +#### Dirty State Override + +```bash +# Override dirty state to true +zerv version --dirty + +# Override dirty state to false +zerv version --no-dirty + +# Force clean release state (distance=0, dirty=false) +zerv version --clean +``` + +#### Branch and Commit Override + +```bash +# Override current branch name +zerv version --current-branch feature-branch + +# Override commit hash (full or short form) +zerv version --commit-hash abc1234 +zerv version --commit-hash abc1234567890abcdef1234567890abcdef123456 +``` + +### Version Component Overrides + +Override specific version components: + +#### Core Version Components + +```bash +# Override major version number +zerv version --major 2 + +# Override minor version number +zerv version --minor 5 + +# Override patch version number +zerv version --patch 10 +``` + +#### Extended Version Components + +```bash +# Override epoch number +zerv version --epoch 1 + +# Override post number +zerv version --post 3 + +# Override dev number +zerv version --dev 7 + +# Override pre-release label (alpha, beta, rc) +zerv version --pre-release-label beta + +# Override pre-release number +zerv version --pre-release-num 2 +``` + +#### Custom Variables + +```bash +# Override custom variables in JSON format +zerv version --custom '{"build_id": "123", "environment": "prod"}' +``` + +### Schema Component Overrides + +Override schema components by index: + +```bash +# Override core schema component by index=value +zerv version --core 0=5 --core 1=2 + +# Override extra-core schema component by index=value +zerv version --extra-core 0=alpha --extra-core 1=3 + +# Override build schema component by index=value +zerv version --build 0=main --build 1=abc1234 +``` + +### Version Bumping + +#### Field-Based Bumps + +**Core Version Bumps** + +```bash +# Add to major version (default: 1) +zerv version --bump-major +zerv version --bump-major 2 + +# Add to minor version (default: 1) +zerv version --bump-minor +zerv version --bump-minor 3 + +# Add to patch version (default: 1) +zerv version --bump-patch +zerv version --bump-patch 5 +``` + +**Extended Component Bumps** + +```bash +# Add to post number (default: 1) +zerv version --bump-post +zerv version --bump-post 2 + +# Add to dev number (default: 1) +zerv version --bump-dev +zerv version --bump-dev 3 + +# Add to pre-release number (default: 1) +zerv version --bump-pre-release-num +zerv version --bump-pre-release-num 2 + +# Add to epoch number (default: 1) +zerv version --bump-epoch +zerv version --bump-epoch 1 +``` + +**Pre-release Label Bumps** + +```bash +# Bump pre-release label and reset number to 0 +zerv version --bump-pre-release-label alpha +zerv version --bump-pre-release-label beta +zerv version --bump-pre-release-label rc +``` + +#### Schema-Based Bumps + +```bash +# Bump core schema component by index[=value] +zerv version --bump-core 0 +zerv version --bump-core 0=5 + +# Bump extra-core schema component by index[=value] +zerv version --bump-extra-core 0 +zerv version --bump-extra-core 1=2 + +# Bump build schema component by index[=value] +zerv version --bump-build 0 +zerv version --bump-build 1=abc +``` + +#### Context Control + +```bash +# Include VCS context qualifiers (default behavior) +zerv version --bump-context + +# Pure tag version, no VCS context +zerv version --no-bump-context +``` + +### Output Customization + +#### Output Prefix + +```bash +# Add prefix to version output +zerv version --output-prefix v +# Output: v0.7.72-post.4+dev.b3dd492 +``` + +#### Output Template (Handlebars) + +```bash +# Custom template for output formatting +zerv version --output-template "v{{major}}.{{minor}}.{{patch}}-{{bumped_branch}}" +# Output: v0.7.72-dev +``` + +### Directory Control + +```bash +# Change to directory before running command +zerv version -C /path/to/repo +``` + +### Input Format Control + +```bash +# Input format for version string parsing +zerv version --input-format auto # default (auto-detect) +zerv version --input-format semver # semantic versioning +zerv version --input-format pep440 # Python PEP440 +``` + +## Command: `zerv check` + +Validate that version strings conform to specific format requirements. + +### Basic Syntax + +```bash +zerv check [OPTIONS] +``` + +### Format Validation + +```bash +# Validate SemVer format +zerv check "1.2.3" --format semver +# Output: ✓ Valid SemVer format + +# Validate PEP440 format +zerv check "1.2.3.post1" --format pep440 +# Output: ✓ Valid PEP440 format + +# Auto-detect format (default) +zerv check "1.2.3" +``` + +## Practical Examples + +### Basic Usage + +```bash +# Get current version from git +zerv version + +# Get version in PEP440 format +zerv version --output-format pep440 + +# Use calendar versioning schema +zerv version --schema zerv-calver +``` + +### Testing and Simulation + +```bash +# Simulate a specific version state +zerv version --tag-version v2.0.0 --distance 5 --dirty + +# Test clean release state +zerv version --clean + +# Override multiple components +zerv version --tag-version v1.5.0 --current-branch feature --dirty +``` + +### Version Bumping + +```bash +# Bump patch version +zerv version --bump-patch + +# Bump minor version (resets patch to 0) +zerv version --bump-minor + +# Bump major version (resets minor and patch to 0) +zerv version --bump-major + +# Multiple bumps with semantic versioning rules +zerv version --bump-major --bump-minor 2 +# Result: major incremented, then minor set to 2, patch reset to 0 +``` + +### CI/CD Integration + +```bash +# Generate version with custom build metadata +zerv version --custom '{"build_id": "123", "environment": "prod"}' + +# Generate version for different package managers +zerv version --output-format semver > VERSION +zerv version --output-format pep440 > python/VERSION + +# Use in different repository +zerv version -C /path/to/repo --output-format pep440 +``` + +### Piping Workflows + +```bash +# Convert between formats +zerv version --output-format zerv | zerv version --source stdin --output-format pep440 + +# Apply different schema to same data +zerv version --output-format zerv | \ + zerv version --source stdin --schema zerv-calver --output-format semver + +# Complex transformation pipeline +zerv version --schema zerv-standard --output-format zerv | \ + zerv version --source stdin --schema zerv-calver --output-format pep440 +``` + +### Custom Templates + +```bash +# Dynamic version based on branch +zerv version --output-template "{{major}}.{{minor}}.{{patch}}-{{bumped_branch}}" + +# Include commit hash +zerv version --output-template "v{{major}}.{{minor}}.{{patch}}+{{bumped_commit_hash_short}}" + +# Custom format with build metadata +zerv version --custom '{"build": "123"}' \ + --output-template "{{major}}.{{minor}}.{{patch}}-build.{{custom.build}}" +``` + +## Zerv RON Format + +The Zerv RON (Rusty Object Notation) format provides complete version data for piping and debugging: + +### Structure + +```ron +( + schema: ( + core: [var(Major), var(Minor), var(Patch)], + extra_core: [var(Epoch), var(PreRelease), var(Post)], + build: [var(BumpedBranch), var(BumpedCommitHashShort)], + precedence_order: [Epoch, Major, Minor, Patch, Core, PreReleaseLabel, PreReleaseNum, Post, Dev, ExtraCore, Build], + ), + vars: ( + major: Some(0), + minor: Some(7), + patch: Some(72), + epoch: None, + pre_release: None, + post: Some(4), + dev: None, + distance: Some(4), + dirty: Some(false), + bumped_branch: Some("dev"), + bumped_commit_hash: Some("b3dd4923dd7f0a06520761b59139924e283b9a19"), + bumped_timestamp: None, + last_branch: None, + last_commit_hash: None, + last_timestamp: Some(1760630416), + custom: (), + ), +) +``` + +### Schema Components + +#### Component Types + +- `var(FieldName)` - Variable references (e.g., `var(Major)`, `var(BumpedBranch)`) +- `ts(Pattern)` - Timestamp patterns (e.g., `ts(YYYY)`, `ts(compact_date)`) +- `str("literal")` - String literals +- `int(123)` - Integer literals + +#### Available Variables + +- **Core Version**: `Major`, `Minor`, `Patch`, `Epoch` +- **Pre-release**: `PreRelease` +- **Post-release**: `Post`, `Dev` +- **VCS State**: `Distance`, `Dirty`, `BumpedBranch`, `BumpedCommitHashShort` +- **Timestamps**: `BumpedTimestamp`, `LastTimestamp` +- **Custom**: Access via nested paths in custom object + +### Schema Sections + +#### Core + +Primary version components that determine version precedence. + +#### Extra Core + +Additional version metadata (epoch, pre-release, post-release). + +#### Build + +Build and VCS metadata that doesn't affect version precedence. + +## Template Variables + +When using `--output-template`, the following variables are available: + +### Version Components + +- `{{major}}`, `{{minor}}`, `{{patch}}` - Core version numbers +- `{{epoch}}` - PEP440 epoch number +- `{{post}}` - Post-release number +- `{{dev}}` - Development number + +### Pre-release Components + +- `{{pre_release.label}}` - Pre-release label (alpha, beta, rc) +- `{{pre_release.num}}` - Pre-release number + +### VCS Data + +- `{{distance}}` - Commits since tag +- `{{dirty}}` - Working tree state (true/false) +- `{{bumped_branch}}` - Current branch name +- `{{bumped_commit_hash}}` - Full commit hash +- `{{bumped_commit_hash_short}}` - Short commit hash (7 chars) +- `{{bumped_timestamp}}` - Commit timestamp +- `{{last_commit_hash}}` - Last version commit hash +- `{{last_branch}}` - Branch where last version was created +- `{{last_timestamp}}` - Last version creation timestamp + +### Custom Variables + +- `{{custom.*}}` - Any custom variables (e.g., `{{custom.build_id}}`) + +## Error Handling + +### Common Errors + +**Not in a Git Repository** + +```bash +Error: Not in a git directory. Use -C to specify directory or --source stdin to parse version string +``` + +**Solution**: Use `-C ` or `--source stdin` + +**Invalid Schema** + +```bash +Error: Failed to parse RON schema: expected ')' at line 3, column 15 +``` + +**Solution**: Validate RON syntax + +**Conflicting Options** + +```bash +Error: Cannot use --dirty and --no-dirty together +Error: Cannot use --clean with --distance (conflicting options) +``` + +**Solution**: Use only one conflicting option + +### Exit Codes + +- `0` - Success +- `1` - Error (invalid input, not a git repo, etc.) + +## Version State Tiers + +Zerv automatically determines version state based on VCS conditions: + +### Tier 1: Clean Tagged Release + +- **Condition**: `distance = 0`, `dirty = false` +- **Example**: `1.2.3` + +### Tier 2: Post-Release (Distance) + +- **Condition**: `distance > 0`, `dirty = false` +- **Example**: `1.2.3-post.4+dev.b3dd492` + +### Tier 3: Development (Dirty) + +- **Condition**: `dirty = true` +- **Example**: `1.2.3+dev.5.b3dd492` + +## Best Practices + +### CI/CD Integration + +1. **Use specific output formats** for different package managers +2. **Override VCS state** for testing scenarios +3. **Use piping** for complex version transformations +4. **Validate versions** with `zerv check` before publishing + +### Version Bumping + +1. **Follow semantic versioning rules** - higher-level bumps reset lower components +2. **Use context control** to include/exclude VCS metadata +3. **Test with overrides** before applying to real repositories + +### Schema Design + +1. **Use preset schemas** for standard cases +2. **Create custom schemas** for specialized versioning needs +3. **Test schema behavior** with different VCS states + +## Advanced Features + +### Handlebars Templating + +Zerv supports Handlebars templating in: + +- Output templates (`--output-template`) +- Override values (most override options) +- Bump values (most bump options) + +### Schema-Based Operations + +- Override schema components by index +- Bump schema components independently +- Custom precedence ordering + +### Piping Support + +- Full data preservation through Zerv RON format +- Format conversion between SemVer, PEP440, and Zerv +- Multi-step processing pipelines + +## Migration and Compatibility + +### From Other Tools + +Zerv can parse existing version strings in multiple formats: + +- Semantic Versioning (SemVer) +- Python PEP440 +- Auto-detection for mixed environments + +### Integration Points + +- **Git repositories** - Primary VCS integration +- **CI/CD pipelines** - Override capabilities for testing +- **Package managers** - Multiple output format support +- **Build systems** - Template-based custom formatting diff --git a/.dev/27-integration-tests-revamp-plan.md b/.dev/27-integration-tests-revamp-plan.md new file mode 100644 index 0000000..6da33e3 --- /dev/null +++ b/.dev/27-integration-tests-revamp-plan.md @@ -0,0 +1,217 @@ +# Integration Tests Revamp Plan + +## Current State + +Integration tests are currently disabled for faster development. The existing `tests/integration_tests/version` folder contains tests that use too many git fixtures, causing slow test execution due to Docker overhead. + +## Existing Codebase Assets + +The codebase already provides excellent test utilities in `src/test_utils/`: + +- **`ZervFixture`**: Complete Zerv object creation with chainable methods +- **`ZervVarsFixture`**: ZervVars creation with version components +- **`ZervSchemaFixture`**: Schema creation with tier presets +- **`GitRepoFixture`**: Git repository creation (tagged, with_distance, dirty) +- **`TestCommand`**: Command execution utilities + +These existing fixtures eliminate the need to create new RON files and provide type-safe, maintainable test data creation. + +## Problem Analysis + +1. **Git Fixture Overuse**: Current tests create too many git repositories via Docker, making tests slow +2. **Inefficient Test Structure**: Tests don't follow the CLI argument structure, making them hard to maintain +3. **Missing Coverage**: Some CLI features may not be adequately tested +4. **Performance Issues**: Docker-based git tests are necessary but should be minimized + +## Solution Strategy + +### 1. Minimize Git Dependencies + +- **Limit Git Tests**: Use ≤5 test cases that actually require git fixtures +- **Use Zerv RON Fixtures**: Convert git states to Zerv RON format and use `--source stdin` for most tests +- **Focus Git Tests**: Only test git-specific functionality (VCS detection, branch parsing, etc.) + +### 2. Restructure Test Organization + +Organize tests to mirror `VersionArgs` structure: + +``` +tests/integration_tests/version/ +├── main/ # MainConfig tests +│ ├── sources/ # --source git/stdin (git tests here) +│ │ ├── stdin.rs # --source stdin tests +│ │ └── git.rs # Basic git integration (≤3 tests total) +│ ├── formats.rs # --input-format, --output-format +│ ├── schemas.rs # --schema, --schema-ron +│ ├── templates.rs # --output-template +│ ├── directory.rs # -C flag +│ └── combinations.rs # MainConfig combinations +├── overrides/ # OverridesConfig tests +│ ├── vcs.rs # --tag-version, --distance, --dirty, etc. +│ ├── components.rs # --major, --minor, --patch, etc. +│ ├── schema_components.rs # --core, --extra-core, --build +│ └── combinations.rs # OverridesConfig combinations +├── bumps/ # BumpsConfig tests +│ ├── field_based.rs # --bump-major, --bump-minor, etc. +│ ├── schema_based.rs # --bump-core, --bump-extra-core, etc. +│ ├── context.rs # --bump-context, --no-bump-context +│ └── combinations.rs # BumpsConfig combinations +└── combinations/ # Cross-module integration tests + ├── override_bump.rs # Overrides + Bumps + ├── schema_override.rs # Schema + Overrides + └── full_workflow.rs # Complete workflows +``` + +### 3. Test Strategy by Category + +#### Main Config Tests (`main/`) + +- **Focus**: Individual MainConfig options in isolation +- **Method**: Primarily stdin-based with ZervFixture +- **Git Usage**: Only in `sources/` subfolder (≤3 total git tests) +- **Scope**: Test each option independently, fix other args to defaults + +#### Override Tests (`overrides/`) + +- **Focus**: OverridesConfig options individually + related combinations +- **Method**: ZervFixture with single/multiple related overrides +- **Git Usage**: None (all via stdin) +- **Scope**: Test individual overrides + combinations within override category + +#### Bump Tests (`bumps/`) + +- **Focus**: BumpsConfig options individually + related combinations +- **Method**: ZervFixture with single/multiple related bumps +- **Git Usage**: None (all via stdin) +- **Scope**: Test individual bumps + combinations within bump category + +#### Combination Tests (`combinations/`) + +- **Focus**: Cross-module interactions and complex scenarios +- **Method**: ZervFixture with multiple options combined +- **Git Usage**: None (all via stdin) +- **Scope**: Test interactions between main/overrides/bumps + +#### Source Tests (`main/sources/`) + +- **Focus**: Source switching and git-specific functionality +- **Method**: stdin tests + minimal Docker git fixtures (≤5 total) +- **Coverage**: stdin source, basic git pipeline integration + +### 4. Implementation Plan + +#### Phase 1: Backup and Setup + +1. **Backup Current Tests** + + ```bash + mv tests/integration_tests/version tests/integration_tests/version_old_backup + ``` + +2. **Enable Integration Tests** + - Uncomment code in `tests/integration.rs`: + ```rust + mod integration_tests; + pub use integration_tests::*; + ``` + - Comment out version module in `tests/integration_tests/mod.rs`: + ```rust + // pub mod version; // Temporarily disabled during revamp + ``` + - Run `make test` and fix any errors (likely test updates, not implementation) + - **Goal**: All integration tests pass except version command tests + - **Note**: If implementation changes needed, ask first before modifying + +3. **Create New Structure** + ```bash + mkdir -p tests/integration_tests/version/{main/sources,overrides,bumps,combinations} + ``` + +#### Phase 2: Implement Main Config Tests (`main/`) + +- Create `tests/integration_tests/version/main/mod.rs` +- Implement individual MainConfig tests: + - `sources/`: git vs stdin (≤3 git tests) + - `formats.rs`: --input-format, --output-format individually + - `schemas.rs`: --schema, --schema-ron individually + - `templates.rs`: --output-template individually + - `directory.rs`: -C flag individually + - `combinations.rs`: MainConfig option combinations +- Use direct imports: `use zerv::test_utils::{ZervFixture, GitRepoFixture};` +- Test and validate main config functionality + +#### Phase 3: Implement Override Tests (`overrides/`) + +- Create `tests/integration_tests/version/overrides/mod.rs` +- Implement individual OverridesConfig tests: + - `vcs.rs`: --tag-version, --distance, --dirty individually + - `components.rs`: --major, --minor, --patch individually + - `schema_components.rs`: --core, --extra-core, --build individually + - `combinations.rs`: Override combinations +- Use ZervFixture with stdin source for all tests +- Test and validate override functionality + +#### Phase 4: Implement Bump Tests (`bumps/`) + +- Create `tests/integration_tests/version/bumps/mod.rs` +- Implement individual BumpsConfig tests: + - `field_based.rs`: --bump-major, --bump-minor individually + - `schema_based.rs`: --bump-core, --bump-extra-core individually + - `context.rs`: --bump-context, --no-bump-context individually + - `combinations.rs`: Bump combinations +- Use ZervFixture with stdin source for all tests +- Test and validate bump functionality + +#### Phase 5: Implement Cross-Module Combinations (`combinations/`) + +- Create `tests/integration_tests/version/combinations/mod.rs` +- Implement cross-module interaction tests: + - `override_bump.rs`: Overrides + Bumps interactions + - `schema_override.rs`: Schema + Overrides interactions + - `full_workflow.rs`: Complete multi-option workflows +- Use ZervFixture with complex scenarios +- Re-enable version module in `tests/integration_tests/mod.rs` +- Run full test suite and validate performance targets +- Test and validate complete integration test system + +### 5. Performance Targets + +- **Total Test Time**: <30 seconds for full integration test suite +- **Git Tests**: ≤3 test cases, <10 seconds total +- **RON Tests**: Majority of tests, <20 seconds total +- **Parallel Execution**: Enable parallel test execution where possible + +### 6. Coverage Goals + +Ensure comprehensive coverage of: + +- All CLI arguments and combinations +- Error conditions and validation +- Format conversions (SemVer ↔ PEP440 ↔ Zerv) +- Schema behavior across different states +- Override and bump interactions +- Template rendering edge cases + +### 7. Maintenance Strategy + +- **Fixture Management**: Leverage existing ZervFixture for consistency +- **Test Organization**: Mirror CLI structure for easy maintenance +- **Documentation**: Document test patterns and fixture usage +- **CI Integration**: Ensure tests run efficiently in CI/CD pipeline + +## Implementation Steps + +1. **Phase 1**: Backup and setup +2. **Phase 2**: Implement main config tests +3. **Phase 3**: Implement override tests +4. **Phase 4**: Implement bump tests +5. **Phase 5**: Implement cross-module combinations and final integration + +## Success Criteria + +- ✅ Integration tests run in <30 seconds +- ✅ ≤3 git-dependent test cases +- ✅ Comprehensive CLI argument coverage +- ✅ Test structure mirrors VersionArgs organization +- ✅ RON fixtures enable fast, reliable testing +- ✅ Easy to add new tests and maintain existing ones From 026f610b28348ba04eba49aa229d2289e2afaf59 Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Sat, 18 Oct 2025 21:20:22 +0700 Subject: [PATCH 06/13] feat: add make docs --- .cargo/config.toml | 4 +- Cargo.toml | 1 + Makefile | 4 + docs/CLI.md | 140 +++++++ xtask/Cargo.lock | 946 +++++++++++++++++++++++++++++++++++++++++++++ xtask/Cargo.toml | 9 + xtask/src/main.rs | 33 ++ 7 files changed, 1135 insertions(+), 2 deletions(-) create mode 100644 docs/CLI.md create mode 100644 xtask/Cargo.lock create mode 100644 xtask/Cargo.toml create mode 100644 xtask/src/main.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 3854177..4b01400 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,2 @@ -[target.'cfg(all())'] -rustflags = ["-D", "clippy::uninlined_format_args"] +[alias] +xtask = "run --manifest-path xtask/Cargo.toml --" diff --git a/Cargo.toml b/Cargo.toml index e9a6963..7145daf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ exclude = [ "/scripts/", "/.dev/", "/.amazonq/", + "/.cursor/", ] [features] diff --git a/Makefile b/Makefile index 2eb6d63..3172c12 100644 --- a/Makefile +++ b/Makefile @@ -95,3 +95,7 @@ test_flaky: open_coverage: open coverage/tarpaulin-report.html + +.PHONY: docs +docs: + cargo xtask generate-docs diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000..5359f24 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,140 @@ +# Command-Line Help for `zerv` + +This document contains the help content for the `zerv` command-line program. + +**Command Overview:** + +- [`zerv`↴](#zerv) +- [`zerv version`↴](#zerv-version) +- [`zerv check`↴](#zerv-check) + +## `zerv` + +Zerv is a dynamic versioning tool that generates version strings from version control system (VCS) data using configurable schemas. It supports multiple input sources, output formats, and advanced override capabilities for CI/CD workflows. + +EXAMPLES: + +# Basic version generation from git + +zerv version + +# Generate PEP440 format with custom schema + +zerv version --output-format pep440 --schema calver + +# Override VCS values for testing + +zerv version --tag-version v2.0.0 --distance 5 --dirty true + +# Force clean release state + +zerv version --clean + +# Pipe Zerv RON between commands + +zerv version --output-format zerv | zerv version --source stdin --schema calver + +# Use in different directory + +zerv version -C /path/to/repo + +**Usage:** `zerv ` + +###### **Subcommands:** + +- `version` — Generate version from VCS data with configurable schemas and overrides +- `check` — Validate version string format compliance + +## `zerv version` + +Generate version strings from version control system data using configurable schemas. +Supports multiple input sources (git, stdin), output formats (semver, pep440, zerv), and VCS overrides +for testing and CI/CD workflows. + +**Usage:** `zerv version [OPTIONS]` + +###### **Options:** + +- `--source ` — Input source: 'git' (extract from repository) or 'stdin' (read Zerv RON format) + + Default value: `git` + + Possible values: `git`, `stdin` + +- `--input-format ` — Input format: 'auto' (detect), 'semver', or 'pep440' + + Default value: `auto` + + Possible values: `auto`, `semver`, `pep440` + +- `-C ` — Change to directory before running command +- `--schema ` — Schema preset name (standard, calver, etc.) +- `--schema-ron ` — Custom schema in RON format +- `--output-format ` — Output format: 'semver' (default), 'pep440', or 'zerv' (RON format for piping) + + Default value: `semver` + + Possible values: `semver`, `pep440`, `zerv` + +- `--output-template ` — Output template for custom formatting (Handlebars syntax) +- `--output-prefix ` — Prefix to add to version output (e.g., 'v' for 'v1.0.0') +- `--tag-version ` — Override detected tag version (e.g., 'v2.0.0', '1.5.0-beta.1') +- `--distance ` — Override distance from tag (number of commits since tag) +- `--dirty` — Override dirty state to true (sets dirty=true) +- `--no-dirty` — Override dirty state to false (sets dirty=false) +- `--clean` — Force clean release state (sets distance=0, dirty=false). Conflicts with --distance and --dirty +- `--current-branch ` — Override current branch name +- `--commit-hash ` — Override commit hash (full or short form) +- `--major ` — Override major version number +- `--minor ` — Override minor version number +- `--patch ` — Override patch version number +- `--epoch ` — Override epoch number +- `--post ` — Override post number +- `--dev ` — Override dev number +- `--pre-release-label ` — Override pre-release label (alpha, beta, rc) + + Possible values: `alpha`, `beta`, `rc` + +- `--pre-release-num ` — Override pre-release number +- `--custom ` — Override custom variables in JSON format +- `--core ` — Override core schema component by index=value (e.g., --core 0=5 or --core 1={{major}}) +- `--extra-core ` — Override extra-core schema component by index=value (e.g., --extra-core 0=5 or --extra-core 1={{branch}}) +- `--build ` — Override build schema component by index=value (e.g., --build 0=5 or --build 1={{commit_short}}) +- `--bump-major ` — Add to major version (default: 1) +- `--bump-minor ` — Add to minor version (default: 1) +- `--bump-patch ` — Add to patch version (default: 1) +- `--bump-post ` — Add to post number (default: 1) +- `--bump-dev ` — Add to dev number (default: 1) +- `--bump-pre-release-num ` — Add to pre-release number (default: 1) +- `--bump-epoch ` — Add to epoch number (default: 1) +- `--bump-pre-release-label ` — Bump pre-release label (alpha, beta, rc) and reset number to 0 + + Possible values: `alpha`, `beta`, `rc` + +- `--bump-core ` — Bump core schema component by index[=value] (e.g., --bump-core 0={{distance}} or --bump-core 0) +- `--bump-extra-core ` — Bump extra-core schema component by index[=value] (e.g., --bump-extra-core 0={{distance}} or --bump-extra-core 0) +- `--bump-build ` — Bump build schema component by index[=value] (e.g., --bump-build 0={{distance}} or --bump-build 0) +- `--bump-context` — Include VCS context qualifiers (default behavior) +- `--no-bump-context` — Pure tag version, no VCS context + +## `zerv check` + +Validate that version strings conform to specific format requirements. +Supports SemVer, PEP440, and other version format validation. + +**Usage:** `zerv check [OPTIONS] ` + +###### **Arguments:** + +- `` — Version string to validate + +###### **Options:** + +- `-f`, `--format ` — Format to validate against + +
+ + +This document was generated automatically by +clap-markdown. + diff --git a/xtask/Cargo.lock b/xtask/Cargo.lock new file mode 100644 index 0000000..4a80241 --- /dev/null +++ b/xtask/Cargo.lock @@ -0,0 +1,946 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +dependencies = [ + "serde", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "cc" +version = "1.2.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4512b90fa68d3a9932cea5184017c5d200f5921df706d45e853537dea51508f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap-markdown" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2a2617956a06d4885b490697b5307ebb09fec10b088afc18c81762d848c2339" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_builder" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0025e98baa12e766c67ba13ff4695a887a1eba19569aad00a472546795bd6730" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "handlebars" +version = "6.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "pest" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "ron" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468" +dependencies = [ + "base64", + "bitflags", + "serde", + "serde_derive", + "unicode-ident", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "clap-markdown", + "zerv", +] + +[[package]] +name = "zerv" +version = "0.0.0" +dependencies = [ + "chrono", + "clap", + "handlebars", + "indexmap", + "libc", + "once_cell", + "regex", + "ron", + "serde", + "serde_json", + "strum", +] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..cc8003c --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "xtask" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +clap-markdown = "0.1" +zerv = { path = ".." } diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..0bcd492 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,33 @@ +use std::fs; +use std::path::Path; +use clap_markdown::MarkdownOptions; +use zerv::cli::parser::Cli; + +fn main() { + let args: Vec = std::env::args().collect(); + + match args.get(1).map(|s| s.as_str()) { + Some("generate-docs") => { + let markdown = clap_markdown::help_markdown_custom::(&MarkdownOptions::new()); + + // Use provided path or default to docs/CLI.md + let output_path = args.get(2).map(|s| s.as_str()).unwrap_or("docs/CLI.md"); + + // Create parent directory if it doesn't exist + if let Some(parent) = Path::new(output_path).parent() { + if !parent.exists() { + fs::create_dir_all(parent).expect("Failed to create output directory"); + } + } + + // Write to file + fs::write(output_path, markdown).expect("Failed to write CLI documentation"); + println!("Generated CLI documentation: {}", output_path); + } + _ => { + eprintln!("Usage: cargo xtask [OPTIONS]"); + eprintln!("Tasks:"); + eprintln!(" generate-docs [PATH] Generate CLI documentation (default: docs/CLI.md)"); + } + } +} From e06006c0c55e17cedf138220be55c1dd9d653af6 Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Sat, 18 Oct 2025 21:34:30 +0700 Subject: [PATCH 07/13] feat: update integration tests from plan 27 step 1 --- .dev/27-integration-tests-revamp-plan.md | 26 +++++++++++-------- tests/integration.rs | 4 +-- tests/integration_tests/check/auto_detect.rs | 3 ++- tests/integration_tests/check/formats.rs | 3 ++- tests/integration_tests/check/validation.rs | 3 ++- tests/integration_tests/cli_help.rs | 11 ++++---- tests/integration_tests/help_flags.rs | 3 ++- tests/integration_tests/mod.rs | 2 +- tests/integration_tests/util/command.rs | 16 +++++++++--- tests/integration_tests/util/mod.rs | 5 +++- .../{version => version_old_backup}/basic.rs | 0 .../{version => version_old_backup}/c_flag.rs | 0 .../command_utils.rs | 0 .../end_to_end.rs | 0 .../{version => version_old_backup}/errors.rs | 0 .../formats.rs | 0 .../git_states.rs | 0 .../{version => version_old_backup}/mod.rs | 0 .../schemas.rs | 0 .../sources.rs | 0 .../zerv_format.rs | 0 21 files changed, 48 insertions(+), 28 deletions(-) rename tests/integration_tests/{version => version_old_backup}/basic.rs (100%) rename tests/integration_tests/{version => version_old_backup}/c_flag.rs (100%) rename tests/integration_tests/{version => version_old_backup}/command_utils.rs (100%) rename tests/integration_tests/{version => version_old_backup}/end_to_end.rs (100%) rename tests/integration_tests/{version => version_old_backup}/errors.rs (100%) rename tests/integration_tests/{version => version_old_backup}/formats.rs (100%) rename tests/integration_tests/{version => version_old_backup}/git_states.rs (100%) rename tests/integration_tests/{version => version_old_backup}/mod.rs (100%) rename tests/integration_tests/{version => version_old_backup}/schemas.rs (100%) rename tests/integration_tests/{version => version_old_backup}/sources.rs (100%) rename tests/integration_tests/{version => version_old_backup}/zerv_format.rs (100%) diff --git a/.dev/27-integration-tests-revamp-plan.md b/.dev/27-integration-tests-revamp-plan.md index 6da33e3..d36f8a9 100644 --- a/.dev/27-integration-tests-revamp-plan.md +++ b/.dev/27-integration-tests-revamp-plan.md @@ -100,33 +100,37 @@ tests/integration_tests/version/ ### 4. Implementation Plan -#### Phase 1: Backup and Setup +#### Phase 1: Backup and Setup ✅ COMPLETED -1. **Backup Current Tests** +1. **Backup Current Tests** ✅ ```bash mv tests/integration_tests/version tests/integration_tests/version_old_backup ``` -2. **Enable Integration Tests** - - Uncomment code in `tests/integration.rs`: +2. **Enable Integration Tests** ✅ + - Uncommented code in `tests/integration.rs`: ```rust mod integration_tests; pub use integration_tests::*; ``` - - Comment out version module in `tests/integration_tests/mod.rs`: + - Commented out version module in `tests/integration_tests/mod.rs`: ```rust // pub mod version; // Temporarily disabled during revamp ``` - - Run `make test` and fix any errors (likely test updates, not implementation) - - **Goal**: All integration tests pass except version command tests - - **Note**: If implementation changes needed, ask first before modifying + - Ran `make test` and fixed one failing test in `cli_help.rs` + - **Goal**: All integration tests pass except version command tests ✅ + - **Result**: 1954 tests pass with 91.96% coverage + +3. **Create New Structure** ✅ -3. **Create New Structure** ```bash mkdir -p tests/integration_tests/version/{main/sources,overrides,bumps,combinations} ``` + - Directory structure created successfully + - Ready for Phase 2 implementation + #### Phase 2: Implement Main Config Tests (`main/`) - Create `tests/integration_tests/version/main/mod.rs` @@ -201,8 +205,8 @@ Ensure comprehensive coverage of: ## Implementation Steps -1. **Phase 1**: Backup and setup -2. **Phase 2**: Implement main config tests +1. **Phase 1**: Backup and setup ✅ **COMPLETED** +2. **Phase 2**: Implement main config tests 🔄 **NEXT** 3. **Phase 3**: Implement override tests 4. **Phase 4**: Implement bump tests 5. **Phase 5**: Implement cross-module combinations and final integration diff --git a/tests/integration.rs b/tests/integration.rs index 8567f61..667034e 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,3 +1,3 @@ -// mod integration_tests; +mod integration_tests; -// pub use integration_tests::*; +pub use integration_tests::*; diff --git a/tests/integration_tests/check/auto_detect.rs b/tests/integration_tests/check/auto_detect.rs index 51f0a7f..1911721 100644 --- a/tests/integration_tests/check/auto_detect.rs +++ b/tests/integration_tests/check/auto_detect.rs @@ -1,6 +1,7 @@ -use super::TestCommand; use rstest::rstest; +use super::TestCommand; + #[rstest] #[case("1.2.3")] #[case("2.0.0")] diff --git a/tests/integration_tests/check/formats.rs b/tests/integration_tests/check/formats.rs index 328c66c..3496d94 100644 --- a/tests/integration_tests/check/formats.rs +++ b/tests/integration_tests/check/formats.rs @@ -1,6 +1,7 @@ -use super::TestCommand; use rstest::rstest; +use super::TestCommand; + #[rstest] #[case("1.2.3")] #[case("1.0.0")] diff --git a/tests/integration_tests/check/validation.rs b/tests/integration_tests/check/validation.rs index 96182be..237bb48 100644 --- a/tests/integration_tests/check/validation.rs +++ b/tests/integration_tests/check/validation.rs @@ -1,6 +1,7 @@ -use super::TestCommand; use rstest::rstest; +use super::TestCommand; + #[rstest] #[case("1.2.3", "Valid PEP440 format")] #[case("1.2.3", "Valid SemVer format")] diff --git a/tests/integration_tests/cli_help.rs b/tests/integration_tests/cli_help.rs index e8ffd39..2ea7e96 100644 --- a/tests/integration_tests/cli_help.rs +++ b/tests/integration_tests/cli_help.rs @@ -1,6 +1,7 @@ -use crate::integration_tests::util::command::TestCommand; use rstest::rstest; +use crate::integration_tests::util::command::TestCommand; + /// Test comprehensive CLI help text and error message consistency /// This validates requirements 9.1, 9.2, 9.3, 9.4, 9.5, 9.6 from the CLI consistency requirements /// Helper struct to mimic the expected result format @@ -264,14 +265,14 @@ fn test_help_shows_future_extension_options() { assert!(result.success, "Help should succeed"); let output = result.stdout; - assert!( - output.contains("future extension"), - "Should mark future extension options" - ); assert!( output.contains("--output-template"), "Should show template option" ); + assert!( + output.contains("Handlebars syntax"), + "Should mention Handlebars syntax" + ); } #[test] diff --git a/tests/integration_tests/help_flags.rs b/tests/integration_tests/help_flags.rs index b8f2fce..c42c627 100644 --- a/tests/integration_tests/help_flags.rs +++ b/tests/integration_tests/help_flags.rs @@ -1,6 +1,7 @@ -use super::TestCommand; use rstest::rstest; +use super::TestCommand; + #[rstest] #[case("-V")] #[case("--version")] diff --git a/tests/integration_tests/mod.rs b/tests/integration_tests/mod.rs index 1be29da..31e3b00 100644 --- a/tests/integration_tests/mod.rs +++ b/tests/integration_tests/mod.rs @@ -2,7 +2,7 @@ pub mod check; pub mod cli_help; pub mod help_flags; pub mod util; -pub mod version; +// pub mod version; // Temporarily disabled during revamp use util::TestCommand; use zerv::test_utils::GitRepoFixture; diff --git a/tests/integration_tests/util/command.rs b/tests/integration_tests/util/command.rs index 1e2f131..621d8de 100644 --- a/tests/integration_tests/util/command.rs +++ b/tests/integration_tests/util/command.rs @@ -1,7 +1,13 @@ use std::ffi::OsStr; use std::io; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; +use std::path::{ + Path, + PathBuf, +}; +use std::process::{ + Command, + Output, +}; use zerv::test_utils::TestOutput; @@ -126,10 +132,12 @@ impl TestCommand { #[cfg(test)] mod tests { - use super::*; - use rstest::rstest; use std::process::Command; + use rstest::rstest; + + use super::*; + #[test] fn test_command_new() { let cmd = TestCommand::new(); diff --git a/tests/integration_tests/util/mod.rs b/tests/integration_tests/util/mod.rs index d5162e3..6f386d4 100644 --- a/tests/integration_tests/util/mod.rs +++ b/tests/integration_tests/util/mod.rs @@ -2,4 +2,7 @@ pub mod command; // Re-export from main crate test_utils pub use command::TestCommand; -pub use zerv::test_utils::{TestDir, TestOutput}; +pub use zerv::test_utils::{ + TestDir, + TestOutput, +}; diff --git a/tests/integration_tests/version/basic.rs b/tests/integration_tests/version_old_backup/basic.rs similarity index 100% rename from tests/integration_tests/version/basic.rs rename to tests/integration_tests/version_old_backup/basic.rs diff --git a/tests/integration_tests/version/c_flag.rs b/tests/integration_tests/version_old_backup/c_flag.rs similarity index 100% rename from tests/integration_tests/version/c_flag.rs rename to tests/integration_tests/version_old_backup/c_flag.rs diff --git a/tests/integration_tests/version/command_utils.rs b/tests/integration_tests/version_old_backup/command_utils.rs similarity index 100% rename from tests/integration_tests/version/command_utils.rs rename to tests/integration_tests/version_old_backup/command_utils.rs diff --git a/tests/integration_tests/version/end_to_end.rs b/tests/integration_tests/version_old_backup/end_to_end.rs similarity index 100% rename from tests/integration_tests/version/end_to_end.rs rename to tests/integration_tests/version_old_backup/end_to_end.rs diff --git a/tests/integration_tests/version/errors.rs b/tests/integration_tests/version_old_backup/errors.rs similarity index 100% rename from tests/integration_tests/version/errors.rs rename to tests/integration_tests/version_old_backup/errors.rs diff --git a/tests/integration_tests/version/formats.rs b/tests/integration_tests/version_old_backup/formats.rs similarity index 100% rename from tests/integration_tests/version/formats.rs rename to tests/integration_tests/version_old_backup/formats.rs diff --git a/tests/integration_tests/version/git_states.rs b/tests/integration_tests/version_old_backup/git_states.rs similarity index 100% rename from tests/integration_tests/version/git_states.rs rename to tests/integration_tests/version_old_backup/git_states.rs diff --git a/tests/integration_tests/version/mod.rs b/tests/integration_tests/version_old_backup/mod.rs similarity index 100% rename from tests/integration_tests/version/mod.rs rename to tests/integration_tests/version_old_backup/mod.rs diff --git a/tests/integration_tests/version/schemas.rs b/tests/integration_tests/version_old_backup/schemas.rs similarity index 100% rename from tests/integration_tests/version/schemas.rs rename to tests/integration_tests/version_old_backup/schemas.rs diff --git a/tests/integration_tests/version/sources.rs b/tests/integration_tests/version_old_backup/sources.rs similarity index 100% rename from tests/integration_tests/version/sources.rs rename to tests/integration_tests/version_old_backup/sources.rs diff --git a/tests/integration_tests/version/zerv_format.rs b/tests/integration_tests/version_old_backup/zerv_format.rs similarity index 100% rename from tests/integration_tests/version/zerv_format.rs rename to tests/integration_tests/version_old_backup/zerv_format.rs From 4e992cc75a10269f1b31067bd4a664746fd32159 Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Sun, 19 Oct 2025 09:20:48 +0700 Subject: [PATCH 08/13] feat: implement integration tests for sources args --- .dev/27-integration-tests-revamp-plan.md | 31 ++- CLAUDE.md | 254 ++++++++++++++++++ Cargo.lock | 1 + Cargo.toml | 1 + src/cli/utils/template/context.rs | 14 +- src/test_utils/zerv/vars.rs | 28 +- src/test_utils/zerv/zerv.rs | 28 +- tests/integration_tests/check/mod.rs | 2 +- tests/integration_tests/help_flags.rs | 10 +- tests/integration_tests/mod.rs | 20 +- tests/integration_tests/util/command.rs | 144 +++++++--- tests/integration_tests/version/main/mod.rs | 1 + .../version/main/sources/git.rs | 79 ++++++ .../version/main/sources/mod.rs | 2 + .../version/main/sources/stdin.rs | 100 +++++++ tests/integration_tests/version/mod.rs | 6 + 16 files changed, 616 insertions(+), 105 deletions(-) create mode 100644 CLAUDE.md create mode 100644 tests/integration_tests/version/main/mod.rs create mode 100644 tests/integration_tests/version/main/sources/git.rs create mode 100644 tests/integration_tests/version/main/sources/mod.rs create mode 100644 tests/integration_tests/version/main/sources/stdin.rs create mode 100644 tests/integration_tests/version/mod.rs diff --git a/.dev/27-integration-tests-revamp-plan.md b/.dev/27-integration-tests-revamp-plan.md index d36f8a9..d3c2c08 100644 --- a/.dev/27-integration-tests-revamp-plan.md +++ b/.dev/27-integration-tests-revamp-plan.md @@ -131,18 +131,25 @@ tests/integration_tests/version/ - Directory structure created successfully - Ready for Phase 2 implementation -#### Phase 2: Implement Main Config Tests (`main/`) - -- Create `tests/integration_tests/version/main/mod.rs` -- Implement individual MainConfig tests: - - `sources/`: git vs stdin (≤3 git tests) - - `formats.rs`: --input-format, --output-format individually - - `schemas.rs`: --schema, --schema-ron individually - - `templates.rs`: --output-template individually - - `directory.rs`: -C flag individually - - `combinations.rs`: MainConfig option combinations -- Use direct imports: `use zerv::test_utils::{ZervFixture, GitRepoFixture};` -- Test and validate main config functionality +#### Phase 2: Implement Main Config Tests (`main/`) 🔄 IN PROGRESS + +- ✅ Created `tests/integration_tests/version/main/mod.rs` +- ✅ Implemented `sources/` tests: + - `sources/stdin.rs`: 6 stdin tests using `ZervFixture` with `TestCommand.stdin()` (✅ PASSED) + - `sources/git.rs`: 1 comprehensive git integration test with Docker gating (✅ PASSED) +- ✅ Enhanced `TestCommand` with `.stdin()` support for cleaner testing +- ✅ Refactored tests to use `rstest` for cleaner parameterized testing +- ✅ Enhanced `ZervFixture.with_vcs_data()` to accept `Option` types for better flexibility +- **Result**: 7 tests passing (100% success rate) +- **Performance**: Tests run in <0.3 seconds without Docker + +**Remaining MainConfig Tests:** + +- ❌ `formats.rs`: Test `--input-format` (semver/pep440/zerv) and `--output-format` (semver/pep440/zerv) combinations +- ❌ `schemas.rs`: Test `--schema` (tier1/tier2/tier3) and `--schema-ron` (custom RON schema) options +- ❌ `templates.rs`: Test `--output-template` with Handlebars template rendering +- ❌ `directory.rs`: Test `-C` flag for changing working directory before execution +- ❌ `combinations.rs`: Test MainConfig option combinations (format + schema, template + format, etc.) #### Phase 3: Implement Override Tests (`overrides/`) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9096a09 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,254 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Zerv is a dynamic versioning CLI tool written in Rust that generates versions for any commit from git and other version control systems. It supports multiple version formats (SemVer, PEP440, CalVer) and is designed for CI/CD builds. + +## Essential Commands + +### Development Setup + +```bash +make setup_dev # Install pre-commit hooks and cargo-tarpaulin +``` + +### Testing + +```bash +make test_easy # Quick tests: Docker Git + Docker tests skipped (fast, coverage gaps) +make test # Full test suite: Docker Git + Docker tests enabled (full coverage) +make test_flaky # Run 5 iterations to detect flaky tests +``` + +### Building and Running + +```bash +make run # Run the CLI with cargo run +cargo build # Build debug version +cargo build --release # Build optimized release version +``` + +### Code Quality + +```bash +make lint # Check code formatting and clippy warnings +make update # Update Rust toolchain and dependencies +``` + +### Coverage + +```bash +make test # Generates coverage reports +make open_coverage # Open HTML coverage report +``` + +### Documentation + +```bash +make docs # Generate documentation via cargo xtask +``` + +## High-Level Architecture + +### Pipeline Architecture + +The core processing follows a clear pipeline pattern: + +``` +Input → VCS Detection → Version Parsing → Transformation → Format Output +``` + +**Key Modules:** + +- **`src/vcs/`**: Version Control System abstraction (currently Git only) + - Detects VCS repositories and extracts metadata + - `VcsData` struct contains tag versions, distance, commits, branches, timestamps + +- **`src/version/`**: Version format implementations + - `VersionObject`: Universal internal representation + - `PEP440`: Python versioning standard + - `SemVer`: Semantic versioning + - `Zerv`: Custom component-based format with variable references + +- **`src/pipeline/`**: Data transformation layer + - `parse_version_from_tag()`: Extracts version from git tags + - `vcs_data_to_zerv_vars()`: Converts VCS metadata to Zerv variables + +- **`src/schema/`**: Schema and preset management + - Presets for common versioning schemes (standard, calver) + - RON-based schema parsing for custom formats + +- **`src/cli/`**: Command-line interface (in development) + - Main commands: `version`, `check` + - Output formatting and display logic + +### State-Based Versioning Tiers + +Zerv uses a three-tier system based on repository state: + +- **Tier 1** (Tagged, clean): `major.minor.patch` +- **Tier 2** (Distance, clean): `major.minor.patch.post+branch.` +- **Tier 3** (Dirty): `major.minor.patch.dev+branch.` + +### Test Infrastructure + +The project has extensive test utilities in `src/test_utils/`: + +- **Environment-aware Git testing**: Uses `DockerGit` locally, `NativeGit` in CI +- **`GitOperations` trait**: Unified interface for both implementations +- **`GitRepoFixture`**: Creates isolated test repositories with specific states +- **`TestDir`**: Temporary directory management with automatic cleanup + +## Testing Standards + +### Environment Variables + +- `ZERV_TEST_NATIVE_GIT=true`: Use native Git (set in CI for platform testing) +- `ZERV_TEST_DOCKER=true`: Enable Docker-dependent tests (requires Docker) + +### Git Testing Pattern + +ALWAYS use the environment-aware pattern: + +```rust +use crate::test_utils::{GitOperations, get_git_impl}; + +let git_impl = get_git_impl(); // Returns DockerGit or NativeGit based on environment +git_impl.init_repo(&test_dir)?; +``` + +### Docker Test Gating + +For Docker-dependent tests: + +```rust +use crate::test_utils::should_run_docker_tests; + +#[test] +fn test_docker_functionality() { + if !should_run_docker_tests() { + return; // Skip when Docker tests are disabled + } + // Test code here +} +``` + +### Flaky Test Prevention + +- Use `GitOperations` trait methods for atomic operations +- Create fresh Git implementations for each test directory +- Include detailed error messages with context +- Verify intermediate states (e.g., `.git` directory exists) +- Never reuse Git implementations across different directories + +## Coding Standards + +### Error Handling + +- **ALWAYS** use `zerv::error::ZervError` for custom errors +- Use `io::Error::other()` instead of `io::Error::new(io::ErrorKind::Other, ...)` +- Include context in error messages for debugging +- Proper error propagation with `?` operator + +### Constants Usage + +**MANDATORY**: Always use constants instead of bare strings: + +```rust +// GOOD +use crate::utils::constants::{fields, formats, sources, schema_names}; +match field_name.as_str() { + fields::MAJOR => // ... + fields::MINOR => // ... +} + +// BAD - Never use bare strings +match field_name.as_str() { + "major" => // ... +} +``` + +### Code Reuse + +Before implementing new test utilities: + +- Check `src/test_utils/` for existing infrastructure +- Reuse `TestDir`, `GitOperations` trait, and other helpers +- Use `get_git_impl()` for environment-aware Git operations +- Avoid duplicating code across files + +### Performance Standards + +- Parse 1000+ versions in <100ms +- Minimal VCS command calls (batch when possible) +- Use compiled regex patterns for speed +- Zero-copy string operations where possible + +## CI/CD + +### Multi-Platform Testing + +- **Linux**: Native Git + Docker tests enabled +- **macOS**: Native Git + Docker tests skipped +- **Windows**: Native Git + Docker tests skipped + +### Pre-commit Hooks + +The project uses pre-commit hooks for: + +- Code formatting (rustfmt) +- Linting (clippy) +- Running tests + +### GitHub Actions + +Main workflows: + +- `ci-test.yml`: Runs tests across Linux, macOS, Windows +- `ci-pre-commit.yml`: Validates formatting and linting +- `cd.yml`: Release automation +- `security.yml`: Security scanning with SonarCloud + +## Important Files + +### Development Documentation + +Read `.dev/00-README.md` FIRST before any coding task. All `.dev/` documents use sequential numbering (00, 01, 02...) where higher numbers indicate more recent plans. + +### Cursor Rules (Apply to Claude Code) + +Key rules in `.cursor/rules/`: + +- `dev-workflow.mdc`: Development workflow and git practices +- `testing-standards.mdc`: Comprehensive testing requirements +- `cli-implementation.mdc`: CLI standards and patterns +- `docker-git-testing.mdc`: Docker testing architecture + +## Running Tests for Specific Features + +```bash +# Run all tests +cargo test + +# Run git-related tests +cargo test git + +# Run specific test file +cargo test --test integration_test_name + +# Run with features +cargo test --features test-utils + +# Run a single test +cargo test test_name -- --exact +``` + +## Configuration + +Centralized config in `src/config.rs`: + +- Loads environment variables (`ZERV_TEST_NATIVE_GIT`, `ZERV_TEST_DOCKER`) +- Validates boolean parsing +- Single source of truth for environment configuration diff --git a/Cargo.lock b/Cargo.lock index 119cbc7..cae1b79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1327,6 +1327,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "shlex", "strum", "tempfile", "zerv", diff --git a/Cargo.toml b/Cargo.toml index 7145daf..ba6e5ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,5 +46,6 @@ tempfile = { version = "^3.0", optional = true } [dev-dependencies] rstest = "^0.26.0" serial_test = "^3.0" +shlex = "^1.3" tempfile = "^3.0" zerv = { path = ".", features = ["test-utils"] } diff --git a/src/cli/utils/template/context.rs b/src/cli/utils/template/context.rs index 86658e9..a8c5ec2 100644 --- a/src/cli/utils/template/context.rs +++ b/src/cli/utils/template/context.rs @@ -91,13 +91,13 @@ mod tests { fn vcs_zerv() -> ZervFixture { ZervFixture::new().with_version(1, 2, 3).with_vcs_data( - 0, - true, - "main".to_string(), - "abcdef123456".to_string(), - "xyz789".to_string(), - 1234567890, - "main".to_string(), + Some(0), + Some(true), + Some("main".to_string()), + Some("abcdef123456".to_string()), + Some("xyz789".to_string()), + Some(1234567890), + Some("main".to_string()), ) } diff --git a/src/test_utils/zerv/vars.rs b/src/test_utils/zerv/vars.rs index fc20c12..088253c 100644 --- a/src/test_utils/zerv/vars.rs +++ b/src/test_utils/zerv/vars.rs @@ -105,21 +105,21 @@ impl ZervVarsFixture { #[allow(clippy::too_many_arguments)] pub fn with_vcs_data( mut self, - distance: u64, - dirty: bool, - bumped_branch: String, - bumped_commit_hash: String, - last_commit_hash: String, - last_timestamp: u64, - last_branch: String, + distance: Option, + dirty: Option, + bumped_branch: Option, + bumped_commit_hash: Option, + last_commit_hash: Option, + last_timestamp: Option, + last_branch: Option, ) -> Self { - self.vars.distance = Some(distance); - self.vars.dirty = Some(dirty); - self.vars.bumped_branch = Some(bumped_branch); - self.vars.bumped_commit_hash = Some(bumped_commit_hash); - self.vars.last_commit_hash = Some(last_commit_hash); - self.vars.last_timestamp = Some(last_timestamp); - self.vars.last_branch = Some(last_branch); + self.vars.distance = distance; + self.vars.dirty = dirty; + self.vars.bumped_branch = bumped_branch; + self.vars.bumped_commit_hash = bumped_commit_hash; + self.vars.last_commit_hash = last_commit_hash; + self.vars.last_timestamp = last_timestamp; + self.vars.last_branch = last_branch; self } diff --git a/src/test_utils/zerv/zerv.rs b/src/test_utils/zerv/zerv.rs index 23279e0..e11a7d5 100644 --- a/src/test_utils/zerv/zerv.rs +++ b/src/test_utils/zerv/zerv.rs @@ -213,21 +213,21 @@ impl ZervFixture { #[allow(clippy::too_many_arguments)] pub fn with_vcs_data( mut self, - distance: u64, - dirty: bool, - bumped_branch: String, - bumped_commit_hash: String, - last_commit_hash: String, - last_timestamp: u64, - last_branch: String, + distance: Option, + dirty: Option, + bumped_branch: Option, + bumped_commit_hash: Option, + last_commit_hash: Option, + last_timestamp: Option, + last_branch: Option, ) -> Self { - self.zerv.vars.distance = Some(distance); - self.zerv.vars.dirty = Some(dirty); - self.zerv.vars.bumped_branch = Some(bumped_branch); - self.zerv.vars.bumped_commit_hash = Some(bumped_commit_hash); - self.zerv.vars.last_commit_hash = Some(last_commit_hash); - self.zerv.vars.last_timestamp = Some(last_timestamp); - self.zerv.vars.last_branch = Some(last_branch); + self.zerv.vars.distance = distance; + self.zerv.vars.dirty = dirty; + self.zerv.vars.bumped_branch = bumped_branch; + self.zerv.vars.bumped_commit_hash = bumped_commit_hash; + self.zerv.vars.last_commit_hash = last_commit_hash; + self.zerv.vars.last_timestamp = last_timestamp; + self.zerv.vars.last_branch = last_branch; self } diff --git a/tests/integration_tests/check/mod.rs b/tests/integration_tests/check/mod.rs index fcffada..928effd 100644 --- a/tests/integration_tests/check/mod.rs +++ b/tests/integration_tests/check/mod.rs @@ -2,4 +2,4 @@ pub mod auto_detect; pub mod formats; pub mod validation; -use super::TestCommand; +use crate::util::TestCommand; diff --git a/tests/integration_tests/help_flags.rs b/tests/integration_tests/help_flags.rs index c42c627..fb39f6b 100644 --- a/tests/integration_tests/help_flags.rs +++ b/tests/integration_tests/help_flags.rs @@ -1,6 +1,6 @@ use rstest::rstest; -use super::TestCommand; +use crate::util::TestCommand; #[rstest] #[case("-V")] @@ -13,13 +13,7 @@ fn test_version_flags(#[case] flag: &str) { #[case("-h")] #[case("--help")] fn test_help_flags(#[case] flag: &str) { - TestCommand::new().arg(flag).assert_success(); -} - -#[test] -fn test_help_flag_shows_commands() { - let test_output = TestCommand::new().arg("--help").assert_success(); - + let test_output = TestCommand::new().arg(flag).assert_success(); let stdout = test_output.stdout(); // Should show available commands diff --git a/tests/integration_tests/mod.rs b/tests/integration_tests/mod.rs index 31e3b00..26e3ebb 100644 --- a/tests/integration_tests/mod.rs +++ b/tests/integration_tests/mod.rs @@ -2,22 +2,4 @@ pub mod check; pub mod cli_help; pub mod help_flags; pub mod util; -// pub mod version; // Temporarily disabled during revamp - -use util::TestCommand; -use zerv::test_utils::GitRepoFixture; - -/// Test a version command with output format -pub fn test_version_output_format( - fixture: &GitRepoFixture, - format: &str, -) -> Result> { - let output = TestCommand::new() - .current_dir(fixture.path()) - .arg("version") - .arg("--output-format") - .arg(format) - .assert_success(); - - Ok(output.stdout().to_string()) -} +pub mod version; diff --git a/tests/integration_tests/util/command.rs b/tests/integration_tests/util/command.rs index 621d8de..33d29bd 100644 --- a/tests/integration_tests/util/command.rs +++ b/tests/integration_tests/util/command.rs @@ -1,5 +1,8 @@ use std::ffi::OsStr; -use std::io; +use std::io::{ + self, + Write, +}; use std::path::{ Path, PathBuf, @@ -7,6 +10,7 @@ use std::path::{ use std::process::{ Command, Output, + Stdio, }; use zerv::test_utils::TestOutput; @@ -16,6 +20,7 @@ pub struct TestCommand { cmd: Command, #[allow(dead_code)] current_dir: Option, + stdin_input: Option, } impl Default for TestCommand { @@ -73,6 +78,7 @@ impl TestCommand { Self { cmd, current_dir: None, + stdin_input: None, } } @@ -83,7 +89,6 @@ impl TestCommand { } /// Add multiple arguments to the command - #[allow(dead_code)] pub fn args(&mut self, args: I) -> &mut Self where I: IntoIterator, @@ -93,6 +98,36 @@ impl TestCommand { self } + /// Add arguments from a shell-like string + /// + /// Uses POSIX shell word splitting (via `shlex` crate), which means it behaves + /// exactly like your terminal shell when splitting arguments. + /// + /// Supports: + /// - Single quotes: 'arg with spaces' (preserves everything literally) + /// - Double quotes: "arg with spaces" (allows escape sequences) + /// - Backslash escapes: \' \" \\ \n \t \r + /// - Mixed quoting: --flag="value with 'quotes'" + /// - Flag forms: --source stdin and --source=stdin (both work) + /// + /// Examples: + /// ``` + /// .args_from_str("version --source stdin --output-format semver") + /// .args_from_str("version --template 'v{{major}}.{{minor}}'") + /// .args_from_str(r#"version --template "version {{major}}.{{minor}}""#) + /// .args_from_str(r"version --prefix 'v' --suffix '-dev'") + /// ``` + pub fn args_from_str>(&mut self, args_str: S) -> &mut Self { + if let Some(args) = shlex::split(args_str.as_ref()) { + self.cmd.args(args); + } else { + // If shlex fails to parse (e.g., unclosed quote), pass the string as-is + // This will likely fail when the command runs, which is the desired behavior + self.cmd.arg(args_str.as_ref()); + } + self + } + /// Set the current directory for the command #[allow(dead_code)] pub fn current_dir>(&mut self, dir: P) -> &mut Self { @@ -101,9 +136,32 @@ impl TestCommand { self } + /// Set stdin input for the command + #[allow(dead_code)] + pub fn stdin>(&mut self, input: S) -> &mut Self { + self.stdin_input = Some(input.into()); + self + } + /// Execute the command and return output pub fn output(&mut self) -> io::Result { - self.cmd.output() + if let Some(ref input) = self.stdin_input { + // Need to use piped stdin + self.cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = self.cmd.spawn()?; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(input.as_bytes())?; + } + + child.wait_with_output() + } else { + self.cmd.output() + } } /// Execute and assert success @@ -132,8 +190,6 @@ impl TestCommand { #[cfg(test)] mod tests { - use std::process::Command; - use rstest::rstest; use super::*; @@ -144,22 +200,6 @@ mod tests { assert!(cmd.current_dir.is_none()); } - #[rstest] - #[case("--version")] - #[case("--help")] - #[case("-V")] - #[case("-h")] - fn test_command_arg_variations(#[case] arg: &str) { - let mut cmd = TestCommand::new(); - cmd.arg(arg); - } - - #[test] - fn test_command_args() { - let mut cmd = TestCommand::new(); - cmd.args(["--version", "--help"]); - } - #[test] fn test_command_current_dir() { let mut cmd = TestCommand::new(); @@ -170,17 +210,61 @@ mod tests { #[test] fn test_command_assert_failure() { - let mut cmd = Command::new("false"); - let output = cmd.output().unwrap(); - assert!(!output.status.success()); - } - - #[test] - fn test_test_command_assert_failure() { - // Create a TestCommand that will fail by using an invalid argument let mut cmd = TestCommand::new(); cmd.arg("--invalid-flag-that-does-not-exist"); let _test_output = cmd.assert_failure(); - // If we reach here, assert_failure worked correctly + } + + #[rstest] + #[case("--version", "zerv")] + #[case("--help", "Usage")] + #[case("-V", "zerv")] + #[case("-h", "Usage")] + fn test_args_from_str_basic_flags(#[case] args: &str, #[case] expected_output: &str) { + let mut cmd = TestCommand::new(); + cmd.args_from_str(args); + let output = cmd.assert_success(); + assert!(output.stdout().contains(expected_output)); + } + + #[rstest] + #[case( + None, + r#"version --source stdin --output-template "v{{major}}.{{minor}}""#, + "v1.2" + )] + #[case( + None, + r#"version --source stdin --output-template "{{major}}.{{minor}}.{{patch}}""#, + "1.2.3" + )] + #[case(Some(2), r#"version --source stdin --output-template "{{#if epoch}}{{epoch}}!{{/if}}{{major}}.{{minor}}.{{patch}}""#, "2!1.2.3")] + #[case( + None, + r#"version --source stdin --output-template "Version {{major}}.{{minor}}""#, + "Version 1.2" + )] + #[case( + None, + r#"version --source=stdin --output-template="v{{major}}.{{minor}}""#, + "v1.2" + )] + fn test_args_from_str_with_templates( + #[case] epoch: Option, + #[case] args: &str, + #[case] expected: &str, + ) { + use zerv::test_utils::ZervFixture; + + let mut fixture = ZervFixture::new().with_version(1, 2, 3); + if let Some(e) = epoch { + fixture = fixture.with_epoch(e); + } + let zerv_ron = fixture.build().to_string(); + + let mut cmd = TestCommand::new(); + cmd.args_from_str(args).stdin(zerv_ron); + let output = cmd.assert_success(); + assert_eq!(output.stdout().trim(), expected); } } diff --git a/tests/integration_tests/version/main/mod.rs b/tests/integration_tests/version/main/mod.rs new file mode 100644 index 0000000..ed814b8 --- /dev/null +++ b/tests/integration_tests/version/main/mod.rs @@ -0,0 +1 @@ +pub mod sources; diff --git a/tests/integration_tests/version/main/sources/git.rs b/tests/integration_tests/version/main/sources/git.rs new file mode 100644 index 0000000..6779f2d --- /dev/null +++ b/tests/integration_tests/version/main/sources/git.rs @@ -0,0 +1,79 @@ +use zerv::test_utils::{ + GitRepoFixture, + ZervFixture, + should_run_docker_tests, +}; +use zerv::version::Zerv; + +use crate::util::TestCommand; + +/// Comprehensive git integration test covering the full pipeline: +/// Git → VCS Detection → Version Parsing → RON Serialization → Deserialization → Validation +#[test] +fn test_git_source_comprehensive() { + if !should_run_docker_tests() { + return; + } + + // Create git fixture with dirty state (tagged v1.2.3 + uncommitted changes) + let fixture = GitRepoFixture::dirty("v1.2.3").expect("Failed to create git repository"); + + // Execute zerv: git source → zerv RON output + let output = TestCommand::new() + .current_dir(fixture.path()) + .args_from_str("version --source git --output-format zerv") + .assert_success(); + + // Parse output back to Zerv object + let parsed_zerv: Zerv = + ron::from_str(output.stdout().trim()).expect("Failed to parse output as Zerv"); + + // Fuzzy check: commit hash should exist and be valid hex + assert!( + parsed_zerv.vars.bumped_commit_hash.is_some(), + "Commit hash should be present" + ); + if let Some(ref hash) = parsed_zerv.vars.bumped_commit_hash { + assert!( + hash.len() >= 7 && hash.len() <= 40, + "Commit hash should be 7-40 chars" + ); + assert!( + hash.chars().all(|c| c.is_ascii_hexdigit()), + "Commit hash should be hex" + ); + } + + // Build expected Zerv object with VCS data + let expected = ZervFixture::new() + .with_version(1, 2, 3) + .with_standard_tier_3() + .with_vcs_data( + Some(0), + Some(true), + Some("main".to_string()), + None, // non-deterministic variable + None, + None, // non-deterministic variables + None, + ) + .build(); + + // Copy non-deterministic timestamp + let mut expected = expected; + expected.vars.bumped_commit_hash = parsed_zerv.vars.bumped_commit_hash.clone(); + expected.vars.last_timestamp = parsed_zerv.vars.last_timestamp; + expected.vars.bumped_timestamp = parsed_zerv.vars.bumped_timestamp; + + // Git source doesn't provide last_branch - it should be None + assert_eq!( + parsed_zerv.vars.last_branch, None, + "Git source should not provide last_branch" + ); + + // Assert the entire Zerv object matches expected + assert_eq!( + parsed_zerv, expected, + "Parsed Zerv should match expected structure" + ); +} diff --git a/tests/integration_tests/version/main/sources/mod.rs b/tests/integration_tests/version/main/sources/mod.rs new file mode 100644 index 0000000..cfb6f56 --- /dev/null +++ b/tests/integration_tests/version/main/sources/mod.rs @@ -0,0 +1,2 @@ +pub mod git; +pub mod stdin; diff --git a/tests/integration_tests/version/main/sources/stdin.rs b/tests/integration_tests/version/main/sources/stdin.rs new file mode 100644 index 0000000..c161150 --- /dev/null +++ b/tests/integration_tests/version/main/sources/stdin.rs @@ -0,0 +1,100 @@ +use rstest::rstest; +use zerv::test_utils::ZervFixture; +use zerv::version::{ + PreReleaseLabel, + Zerv, +}; + +use crate::util::TestCommand; + +#[rstest] +#[case::basic_semver((1, 2, 3), "semver", "1.2.3")] +#[case::basic_pep440((2, 0, 0), "pep440", "2.0.0")] +fn test_stdin_basic_output( + #[case] version: (u64, u64, u64), + #[case] format: &str, + #[case] expected: &str, +) { + let zerv_ron = ZervFixture::new() + .with_version(version.0, version.1, version.2) + .build() + .to_string(); + + let output = TestCommand::new() + .args_from_str(format!("version --source stdin --output-format {format}")) + .stdin(zerv_ron) + .assert_success(); + + assert_eq!(output.stdout().trim(), expected); +} + +#[rstest] +#[case::alpha_semver(PreReleaseLabel::Alpha, Some(1), "semver", "1.0.0-alpha.1")] +#[case::beta_pep440(PreReleaseLabel::Beta, Some(2), "pep440", "1.0.0b2")] +fn test_stdin_with_prerelease( + #[case] label: PreReleaseLabel, + #[case] number: Option, + #[case] format: &str, + #[case] expected: &str, +) { + let zerv_ron = ZervFixture::new() + .with_version(1, 0, 0) + .with_pre_release(label, number) + .build() + .to_string(); + + let output = TestCommand::new() + .args_from_str(format!("version --source stdin --output-format {format}")) + .stdin(zerv_ron) + .assert_success(); + + assert_eq!(output.stdout().trim(), expected); +} + +#[rstest] +#[case::epoch(Some(2), None, "2!1.0.0")] +#[case::post(None, Some(5), "1.0.0.post5")] +fn test_stdin_pep440_features( + #[case] epoch: Option, + #[case] post: Option, + #[case] expected: &str, +) { + let mut fixture = ZervFixture::new().with_version(1, 0, 0); + + if let Some(e) = epoch { + fixture = fixture.with_epoch(e); + } + + if let Some(p) = post { + fixture = fixture.with_post(p); + } + + let zerv_ron = fixture.build().to_string(); + + let output = TestCommand::new() + .args_from_str("version --source stdin --output-format pep440") + .stdin(zerv_ron) + .assert_success(); + + assert_eq!(output.stdout().trim(), expected); +} + +#[test] +fn test_stdin_zerv_roundtrip() { + let original_zerv = ZervFixture::new().with_version(3, 1, 4).build(); + + let zerv_ron = original_zerv.to_string(); + + let output = TestCommand::new() + .args_from_str("version --source stdin --output-format zerv") + .stdin(zerv_ron) + .assert_success(); + + let parsed_zerv: Zerv = + ron::from_str(output.stdout().trim()).expect("Failed to parse output as Zerv RON"); + + assert_eq!( + parsed_zerv, original_zerv, + "Stdin roundtrip should preserve Zerv structure" + ); +} diff --git a/tests/integration_tests/version/mod.rs b/tests/integration_tests/version/mod.rs new file mode 100644 index 0000000..490fcd2 --- /dev/null +++ b/tests/integration_tests/version/mod.rs @@ -0,0 +1,6 @@ +pub mod main; + +// Phase 3-5 modules will be added as they're implemented +// pub mod overrides; +// pub mod bumps; +// pub mod combinations; From a806a295fe3111b21ac5cef6c5e016acda685109 Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Sun, 19 Oct 2025 12:24:00 +0700 Subject: [PATCH 09/13] feat: improve integration test for soruce args --- .dev/27-integration-tests-revamp-plan.md | 6 +- src/test_utils/git/fixtures.rs | 50 +- tests/integration_tests/cli_help.rs | 438 ------------------ tests/integration_tests/help_flags.rs | 102 ++++ tests/integration_tests/mod.rs | 1 - .../version/main/sources/git.rs | 37 ++ .../version/main/sources/mod.rs | 19 + 7 files changed, 189 insertions(+), 464 deletions(-) delete mode 100644 tests/integration_tests/cli_help.rs diff --git a/.dev/27-integration-tests-revamp-plan.md b/.dev/27-integration-tests-revamp-plan.md index d3c2c08..c5d5e93 100644 --- a/.dev/27-integration-tests-revamp-plan.md +++ b/.dev/27-integration-tests-revamp-plan.md @@ -96,7 +96,7 @@ tests/integration_tests/version/ - **Focus**: Source switching and git-specific functionality - **Method**: stdin tests + minimal Docker git fixtures (≤5 total) -- **Coverage**: stdin source, basic git pipeline integration +- **Coverage**: stdin source, basic git pipeline integration, source validation, input format validation ### 4. Implementation Plan @@ -145,7 +145,7 @@ tests/integration_tests/version/ **Remaining MainConfig Tests:** -- ❌ `formats.rs`: Test `--input-format` (semver/pep440/zerv) and `--output-format` (semver/pep440/zerv) combinations +- ❌ `formats.rs`: Test `--input-format` (semver/pep440/zerv) and `--output-format` (semver/pep440/zerv) combinations, format validation errors, error message consistency - ❌ `schemas.rs`: Test `--schema` (tier1/tier2/tier3) and `--schema-ron` (custom RON schema) options - ❌ `templates.rs`: Test `--output-template` with Handlebars template rendering - ❌ `directory.rs`: Test `-C` flag for changing working directory before execution @@ -158,7 +158,7 @@ tests/integration_tests/version/ - `vcs.rs`: --tag-version, --distance, --dirty individually - `components.rs`: --major, --minor, --patch individually - `schema_components.rs`: --core, --extra-core, --build individually - - `combinations.rs`: Override combinations + - `combinations.rs`: Override combinations, conflicting options (clean vs distance/dirty), boolean flag behavior - Use ZervFixture with stdin source for all tests - Test and validate override functionality diff --git a/src/test_utils/git/fixtures.rs b/src/test_utils/git/fixtures.rs index 0f598a5..f29ec6e 100644 --- a/src/test_utils/git/fixtures.rs +++ b/src/test_utils/git/fixtures.rs @@ -11,57 +11,63 @@ pub struct GitRepoFixture { } impl GitRepoFixture { - /// Create a repository with a clean tag (Tier 1: major.minor.patch) - pub fn tagged(tag: &str) -> Result> { + /// Create an empty repository without any tags + pub fn empty() -> Result> { let test_dir = TestDir::new()?; let git_impl = get_git_impl(); - // Perform atomic Git operations in sequence with error context + // Perform atomic Git operations with error context git_impl .init_repo(&test_dir) .map_err(|e| format!("Failed to initialize Git repo: {e}"))?; - // Verify repository was created properly before tagging + // Verify repository was created properly if !test_dir.path().join(".git").exists() { return Err("Git repository was not properly initialized".into()); } - git_impl - .create_tag(&test_dir, tag) + Ok(Self { test_dir, git_impl }) + } + + /// Create a repository with a clean tag (Tier 1: major.minor.patch) + pub fn tagged(tag: &str) -> Result> { + let fixture = Self::empty()?; + + fixture + .git_impl + .create_tag(&fixture.test_dir, tag) .map_err(|e| format!("Failed to create tag '{tag}': {e}"))?; - Ok(Self { test_dir, git_impl }) + Ok(fixture) } /// Create a repository with distance from tag (Tier 2: major.minor.patch.post+branch.) pub fn with_distance(tag: &str, commits: u32) -> Result> { - let test_dir = TestDir::new()?; - let git_impl = get_git_impl(); - - git_impl.init_repo(&test_dir)?; - git_impl.create_tag(&test_dir, tag)?; + let fixture = Self::tagged(tag)?; // Create additional commits for distance for i in 0..commits { - test_dir.create_file(format!("file{}.txt", i + 1), "content")?; - git_impl.create_commit(&test_dir, &format!("Commit {}", i + 1))?; + fixture + .test_dir + .create_file(format!("file{}.txt", i + 1), "content")?; + fixture + .git_impl + .create_commit(&fixture.test_dir, &format!("Commit {}", i + 1))?; } - Ok(Self { test_dir, git_impl }) + Ok(fixture) } /// Create a repository with dirty working directory (Tier 3: major.minor.patch.dev+branch.) pub fn dirty(tag: &str) -> Result> { - let test_dir = TestDir::new()?; - let git_impl = get_git_impl(); - - git_impl.init_repo(&test_dir)?; - git_impl.create_tag(&test_dir, tag)?; + let fixture = Self::tagged(tag)?; // Create uncommitted changes to make it dirty - test_dir.create_file("dirty.txt", "uncommitted changes")?; + fixture + .test_dir + .create_file("dirty.txt", "uncommitted changes")?; - Ok(Self { test_dir, git_impl }) + Ok(fixture) } /// Get the path to the test directory diff --git a/tests/integration_tests/cli_help.rs b/tests/integration_tests/cli_help.rs deleted file mode 100644 index 2ea7e96..0000000 --- a/tests/integration_tests/cli_help.rs +++ /dev/null @@ -1,438 +0,0 @@ -use rstest::rstest; - -use crate::integration_tests::util::command::TestCommand; - -/// Test comprehensive CLI help text and error message consistency -/// This validates requirements 9.1, 9.2, 9.3, 9.4, 9.5, 9.6 from the CLI consistency requirements -/// Helper struct to mimic the expected result format -struct CommandResult { - success: bool, - stdout: String, - stderr: String, -} - -/// Helper function to run zerv command and return result -fn run_zerv_command(args: &[&str]) -> CommandResult { - let mut cmd = TestCommand::new(); - for arg in args { - cmd.arg(arg); - } - - let output = cmd.output().expect("Failed to execute command"); - CommandResult { - success: output.status.success(), - stdout: String::from_utf8_lossy(&output.stdout).to_string(), - stderr: String::from_utf8_lossy(&output.stderr).to_string(), - } -} - -#[test] -fn test_main_help_contains_examples() { - let result = run_zerv_command(&["--help"]); - assert!(result.success, "Help command should succeed"); - - let output = result.stdout; - - // Should contain comprehensive description - assert!( - output.contains("dynamic versioning tool"), - "Should contain main description" - ); - assert!( - output.contains("version control system"), - "Should mention VCS" - ); - assert!( - output.contains("configurable schemas"), - "Should mention schemas" - ); - - // Should contain examples section - assert!( - output.contains("EXAMPLES:"), - "Should contain examples section" - ); - assert!(output.contains("zerv version"), "Should show basic usage"); - assert!( - output.contains("--output-format pep440"), - "Should show format example" - ); - assert!( - output.contains("--tag-version v2.0.0"), - "Should show override example" - ); - assert!(output.contains("--clean"), "Should show clean flag example"); - assert!( - output.contains("Pipe") || output.contains("pipe"), - "Should mention piping" - ); - assert!( - output.contains("-C /path/to/repo"), - "Should show directory example" - ); -} - -#[test] -fn test_version_help_comprehensive() { - let result = run_zerv_command(&["version", "--help"]); - assert!(result.success, "Version help should succeed"); - - let output = result.stdout; - - // Should contain detailed description - assert!( - output.contains("Generate version strings"), - "Should contain detailed description" - ); - assert!( - output.contains("configurable schemas"), - "Should mention schemas" - ); - assert!( - output.contains("multiple input sources"), - "Should mention input sources" - ); - assert!(output.contains("CI/CD workflows"), "Should mention CI/CD"); - - // Should document input sources - assert!(output.contains("git"), "Should document git source"); - assert!(output.contains("stdin"), "Should document stdin source"); - assert!( - output.contains("Zerv RON format"), - "Should mention RON format" - ); - - // Should document output formats - assert!(output.contains("semver"), "Should document semver format"); - assert!(output.contains("pep440"), "Should document pep440 format"); - assert!(output.contains("zerv"), "Should document zerv format"); - - // Should document VCS overrides - assert!( - output.contains("Override detected tag version"), - "Should document tag override" - ); - assert!( - output.contains("Override distance from tag"), - "Should document distance override" - ); - assert!( - output.contains("Override dirty state"), - "Should document dirty override" - ); - assert!( - output.contains("Force clean release state"), - "Should document clean flag" - ); - assert!( - output.contains("Override current branch name"), - "Should document branch override" - ); - assert!( - output.contains("Override commit hash"), - "Should document hash override" - ); - - // Should document boolean flags - assert!(output.contains("--dirty"), "Should document --dirty flag"); - assert!( - output.contains("--no-dirty"), - "Should document --no-dirty flag" - ); - - // Should document conflicts - assert!( - output.contains("Conflicts with --distance and --dirty"), - "Should document conflicts" - ); - - // Should show possible values - assert!( - output.contains("[possible values: git, stdin]"), - "Should show source values" - ); - assert!( - output.contains("[possible values: auto, semver, pep440]"), - "Should show input format values" - ); - assert!( - output.contains("[possible values: semver, pep440, zerv]"), - "Should show output format values" - ); -} - -#[test] -fn test_check_help_available() { - let result = run_zerv_command(&["check", "--help"]); - assert!(result.success, "Check help should succeed"); - - let output = result.stdout; - assert!( - output.contains("Validate"), - "Should contain validation description" - ); -} - -#[test] -fn test_version_flag_shows_version() { - let result = run_zerv_command(&["--version"]); - assert!(result.success, "Version flag should succeed"); - - let output = result.stdout; - assert!(output.contains("zerv"), "Should contain program name"); - // Version should be from Cargo.toml - assert!(!output.trim().is_empty(), "Should not be empty"); -} - -#[test] -fn test_invalid_command_shows_help() { - let result = run_zerv_command(&["invalid-command"]); - assert!(!result.success, "Invalid command should fail"); - - let stderr = result.stderr; - assert!(stderr.contains("error:"), "Should show error"); - assert!( - stderr.contains("For more information, try '--help'"), - "Should suggest help" - ); -} - -#[rstest] -#[case("--output-format", "unknown", "possible values: semver, pep440, zerv")] -#[case("--source", "unknown", "possible values: git, stdin")] -#[case("--input-format", "unknown", "possible values: auto, semver, pep440")] -fn test_unknown_option_value_errors( - #[case] option: &str, - #[case] value: &str, - #[case] expected_possible_values: &str, -) { - let result = run_zerv_command(&["version", option, value]); - assert!(!result.success, "Unknown {option} should fail"); - - let stderr = result.stderr; - assert!( - stderr.contains(&format!("invalid value '{value}'")), - "Should show invalid value" - ); - assert!( - stderr.contains(expected_possible_values), - "Should show possible values" - ); - assert!( - stderr.contains("For more information, try '--help'"), - "Should suggest help" - ); -} - -#[test] -fn test_dirty_flag_without_values() { - // Test that --dirty flag works without requiring values - let result = run_zerv_command(&["version", "--dirty", "--tag-version", "1.0.0"]); - // Command may fail for other reasons (no git repo), but should not fail on flag parsing - if !result.success { - assert!( - !result.stderr.contains("Invalid boolean value"), - "Should not fail on flag parsing, but got error: {}", - result.stderr - ); - } -} - -#[rstest] -#[case(&["--clean", "--distance", "5"], "--distance")] -#[case(&["--clean", "--dirty"], "--dirty")] -fn test_conflicting_options_error(#[case] args: &[&str], #[case] conflicting_flag: &str) { - let mut command_args = vec!["version"]; - command_args.extend(args); - let result = run_zerv_command(&command_args); - assert!(!result.success, "Conflicting options should fail"); - - let stderr = result.stderr; - assert!( - stderr.contains("Conflicting options"), - "Should show conflicting options error" - ); - assert!(stderr.contains("--clean"), "Should mention clean flag"); - assert!( - stderr.contains(conflicting_flag), - "Should mention {conflicting_flag} flag" - ); -} - -#[test] -fn test_help_shows_future_extension_options() { - let result = run_zerv_command(&["version", "--help"]); - assert!(result.success, "Help should succeed"); - - let output = result.stdout; - assert!( - output.contains("--output-template"), - "Should show template option" - ); - assert!( - output.contains("Handlebars syntax"), - "Should mention Handlebars syntax" - ); -} - -#[test] -fn test_help_shows_examples_for_overrides() { - let result = run_zerv_command(&["version", "--help"]); - assert!(result.success, "Help should succeed"); - - let output = result.stdout; - - // Should show examples for tag version - assert!( - output.contains("'v2.0.0'"), - "Should show tag version example" - ); - assert!( - output.contains("'1.5.0-beta.1'"), - "Should show prerelease example" - ); - - // Should show examples for prefix - assert!( - output.contains("'v' for 'v1.0.0'"), - "Should show prefix example" - ); -} - -#[rstest] -#[case("--output-format", "xyz", "possible values:")] -#[case("--source", "xyz", "possible values:")] -fn test_error_message_consistency( - #[case] option: &str, - #[case] value: &str, - #[case] expected_values_text: &str, -) { - let result = run_zerv_command(&["version", option, value]); - - if option == "--dirty" { - // --dirty is now a boolean flag, so --dirty xyz should succeed - assert!(result.success, "Should succeed for --dirty flag"); - assert!( - result.stdout.contains(expected_values_text), - "Should show version output containing {expected_values_text}" - ); - } else { - // Other options should still fail with invalid values - assert!(!result.success, "Should fail"); - let stderr = result.stderr; - assert!( - stderr.contains(&format!("invalid value '{value}'")), - "Should show specific invalid value" - ); - assert!( - stderr.contains(expected_values_text), - "Should show {expected_values_text} values" - ); - } -} - -#[test] -fn test_backward_compatibility_patterns() { - // Test that existing command patterns still work - - // Basic version command should work (may fail due to no git repo, but not due to CLI parsing) - let result = run_zerv_command(&["version"]); - if !result.success { - // Should not fail due to CLI parsing issues - assert!( - !result.stderr.contains("error: "), - "Should not have CLI parsing errors" - ); - } - - // Schema option should work - let result = run_zerv_command(&["version", "--schema", "standard"]); - if !result.success { - // Should not fail due to CLI parsing issues - assert!( - !result.stderr.contains("error: "), - "Should not have CLI parsing errors" - ); - } - - // Output format should work - let result = run_zerv_command(&["version", "--output-format", "pep440"]); - if !result.success { - // Should not fail due to CLI parsing issues - assert!( - !result.stderr.contains("error: "), - "Should not have CLI parsing errors" - ); - } -} - -#[test] -fn test_short_help_vs_long_help() { - // Test short help (-h) - let short_result = run_zerv_command(&["version", "-h"]); - assert!(short_result.success, "Short help should succeed"); - - // Test long help (--help) - let long_result = run_zerv_command(&["version", "--help"]); - assert!(long_result.success, "Long help should succeed"); - - // Long help should contain more information - assert!( - long_result.stdout.len() >= short_result.stdout.len(), - "Long help should be at least as detailed as short help" - ); - - // Both should contain basic information - assert!( - short_result.stdout.contains("Generate version"), - "Short help should contain description" - ); - assert!( - long_result.stdout.contains("Generate version"), - "Long help should contain description" - ); -} - -#[test] -fn test_help_mentions_all_supported_formats() { - let result = run_zerv_command(&["version", "--help"]); - assert!(result.success, "Help should succeed"); - - let output = result.stdout; - - // Should mention all supported formats - assert!(output.contains("semver"), "Should mention semver"); - assert!(output.contains("pep440"), "Should mention pep440"); - assert!(output.contains("zerv"), "Should mention zerv"); - - // Should explain what each format is for - assert!(output.contains("semver"), "Should mention semver"); - assert!(output.contains("pep440"), "Should mention pep440"); - assert!( - output.contains("RON format for piping"), - "Should explain zerv format" - ); -} - -#[test] -fn test_help_explains_piping_workflow() { - let result = run_zerv_command(&["--help"]); - assert!(result.success, "Help should succeed"); - - let output = result.stdout; - - // Should contain piping examples - assert!( - output.contains("Pipe") || output.contains("pipe"), - "Should mention piping" - ); - assert!( - output.contains("--output-format zerv"), - "Should show zerv output for piping" - ); - assert!( - output.contains("--source stdin"), - "Should show stdin input for piping" - ); - assert!(output.contains("|"), "Should show pipe operator"); -} diff --git a/tests/integration_tests/help_flags.rs b/tests/integration_tests/help_flags.rs index fb39f6b..11b2009 100644 --- a/tests/integration_tests/help_flags.rs +++ b/tests/integration_tests/help_flags.rs @@ -23,6 +23,50 @@ fn test_help_flags(#[case] flag: &str) { ); } +#[test] +fn test_main_help_contains_examples() { + let test_output = TestCommand::new().arg("--help").assert_success(); + let stdout = test_output.stdout(); + + // Should contain comprehensive description + assert!( + stdout.contains("dynamic versioning tool"), + "Should contain main description" + ); + assert!( + stdout.contains("version control system"), + "Should mention VCS" + ); + assert!( + stdout.contains("configurable schemas"), + "Should mention schemas" + ); + + // Should contain examples section + assert!( + stdout.contains("EXAMPLES:"), + "Should contain examples section" + ); + assert!(stdout.contains("zerv version"), "Should show basic usage"); + assert!( + stdout.contains("--output-format pep440"), + "Should show format example" + ); + assert!( + stdout.contains("--tag-version v2.0.0"), + "Should show override example" + ); + assert!(stdout.contains("--clean"), "Should show clean flag example"); + assert!( + stdout.contains("Pipe") || stdout.contains("pipe"), + "Should mention piping" + ); + assert!( + stdout.contains("-C /path/to/repo"), + "Should show directory example" + ); +} + #[test] fn test_version_command_help() { let test_output = TestCommand::new() @@ -37,6 +81,48 @@ fn test_version_command_help() { stdout.contains("--output-format") || stdout.contains("--source"), "Version help should show command options: {stdout}" ); + + // Should contain detailed description + assert!( + stdout.contains("Generate version strings"), + "Should contain detailed description" + ); + assert!( + stdout.contains("configurable schemas"), + "Should mention schemas" + ); + assert!( + stdout.contains("multiple input sources"), + "Should mention input sources" + ); + assert!(stdout.contains("CI/CD workflows"), "Should mention CI/CD"); + + // Should document input sources + assert!(stdout.contains("git"), "Should document git source"); + assert!(stdout.contains("stdin"), "Should document stdin source"); + assert!( + stdout.contains("Zerv RON format"), + "Should mention RON format" + ); + + // Should document output formats + assert!(stdout.contains("semver"), "Should document semver format"); + assert!(stdout.contains("pep440"), "Should document pep440 format"); + assert!(stdout.contains("zerv"), "Should document zerv format"); + + // Should show possible values + assert!( + stdout.contains("[possible values: git, stdin]"), + "Should show source values" + ); + assert!( + stdout.contains("[possible values: auto, semver, pep440]"), + "Should show input format values" + ); + assert!( + stdout.contains("[possible values: semver, pep440, zerv]"), + "Should show output format values" + ); } #[test] @@ -53,4 +139,20 @@ fn test_check_command_help() { stdout.contains("--format") || stdout.contains("version"), "Check help should show command options: {stdout}" ); + assert!( + stdout.contains("Validate"), + "Should contain validation description" + ); +} + +#[test] +fn test_invalid_command_shows_help() { + let test_output = TestCommand::new().arg("invalid-command").assert_failure(); + let stderr = test_output.stderr(); + + assert!(stderr.contains("error:"), "Should show error"); + assert!( + stderr.contains("For more information, try '--help'"), + "Should suggest help" + ); } diff --git a/tests/integration_tests/mod.rs b/tests/integration_tests/mod.rs index 26e3ebb..d77482a 100644 --- a/tests/integration_tests/mod.rs +++ b/tests/integration_tests/mod.rs @@ -1,5 +1,4 @@ pub mod check; -pub mod cli_help; pub mod help_flags; pub mod util; pub mod version; diff --git a/tests/integration_tests/version/main/sources/git.rs b/tests/integration_tests/version/main/sources/git.rs index 6779f2d..bd5f4a2 100644 --- a/tests/integration_tests/version/main/sources/git.rs +++ b/tests/integration_tests/version/main/sources/git.rs @@ -1,5 +1,6 @@ use zerv::test_utils::{ GitRepoFixture, + TestDir, ZervFixture, should_run_docker_tests, }; @@ -77,3 +78,39 @@ fn test_git_source_comprehensive() { "Parsed Zerv should match expected structure" ); } + +#[test] +fn test_git_source_not_a_git_repo() { + let test_dir = TestDir::new().expect("Failed to create test directory"); + + let output = TestCommand::new() + .current_dir(test_dir.path()) + .args_from_str("version --source git") + .assert_failure(); + + let stderr = output.stderr(); + assert_eq!( + stderr.trim(), + "Error: VCS not found: Not in a git repository (--source git)" + ); +} + +#[test] +fn test_git_source_no_tag_version() { + if !should_run_docker_tests() { + return; + } + + let fixture = GitRepoFixture::empty().expect("Failed to create git repository"); + + let output = TestCommand::new() + .current_dir(fixture.path()) + .args_from_str("version --source git") + .assert_failure(); + + let stderr = output.stderr(); + assert_eq!( + stderr.trim(), + "Error: No version tags found in git repository" + ); +} diff --git a/tests/integration_tests/version/main/sources/mod.rs b/tests/integration_tests/version/main/sources/mod.rs index cfb6f56..c9368b8 100644 --- a/tests/integration_tests/version/main/sources/mod.rs +++ b/tests/integration_tests/version/main/sources/mod.rs @@ -1,2 +1,21 @@ pub mod git; pub mod stdin; + +use rstest::rstest; + +use crate::util::TestCommand; + +#[rstest] +#[case("unknown")] +#[case("invalid")] +#[case("xyz")] +fn test_invalid_source_error(#[case] invalid_source: &str) { + let output = TestCommand::new() + .args_from_str(format!("version --source {invalid_source}")) + .assert_failure(); + + let stderr = output.stderr(); + assert!(stderr.contains(&format!("invalid value '{invalid_source}'"))); + assert!(stderr.contains("possible values: git, stdin")); + assert!(stderr.contains("For more information, try '--help'")); +} From b7d27a0dc1d5f6379493b5bd56c13ca81a4953ae Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Sun, 19 Oct 2025 14:08:36 +0700 Subject: [PATCH 10/13] feat: implement integration test for version formats args --- .dev/27-integration-tests-revamp-plan.md | 7 +- src/cli/version/args/main.rs | 13 - src/cli/version/args/mod.rs | 6 - .../version/args/tests/combination_tests.rs | 8 - src/cli/version/args/tests/main_tests.rs | 49 ---- src/cli/version/zerv_draft.rs | 137 +++++---- src/test_utils/zerv/zerv.rs | 6 + .../integration_tests/version/main/formats.rs | 276 ++++++++++++++++++ tests/integration_tests/version/main/mod.rs | 1 + .../version/main/sources/stdin.rs | 11 +- 10 files changed, 375 insertions(+), 139 deletions(-) create mode 100644 tests/integration_tests/version/main/formats.rs diff --git a/.dev/27-integration-tests-revamp-plan.md b/.dev/27-integration-tests-revamp-plan.md index c5d5e93..abbaf99 100644 --- a/.dev/27-integration-tests-revamp-plan.md +++ b/.dev/27-integration-tests-revamp-plan.md @@ -140,12 +140,13 @@ tests/integration_tests/version/ - ✅ Enhanced `TestCommand` with `.stdin()` support for cleaner testing - ✅ Refactored tests to use `rstest` for cleaner parameterized testing - ✅ Enhanced `ZervFixture.with_vcs_data()` to accept `Option` types for better flexibility -- **Result**: 7 tests passing (100% success rate) -- **Performance**: Tests run in <0.3 seconds without Docker +- ✅ Implemented `formats.rs`: Comprehensive format conversion tests (30 tests) +- **Result**: 37 tests passing (100% success rate) - 7 source tests + 30 format tests +- **Performance**: Tests run in <0.7 seconds without Docker **Remaining MainConfig Tests:** -- ❌ `formats.rs`: Test `--input-format` (semver/pep440/zerv) and `--output-format` (semver/pep440/zerv) combinations, format validation errors, error message consistency +- ✅ `formats.rs`: Test `--input-format` (semver/pep440/auto) and `--output-format` (semver/pep440/zerv) combinations, format validation errors, error message consistency (✅ PASSED - 30 tests) - ❌ `schemas.rs`: Test `--schema` (tier1/tier2/tier3) and `--schema-ron` (custom RON schema) options - ❌ `templates.rs`: Test `--output-template` with Handlebars template rendering - ❌ `directory.rs`: Test `-C` flag for changing working directory before execution diff --git a/src/cli/version/args/main.rs b/src/cli/version/args/main.rs index 266e681..3d3b396 100644 --- a/src/cli/version/args/main.rs +++ b/src/cli/version/args/main.rs @@ -75,16 +75,3 @@ impl Default for MainConfig { } } } - -impl MainConfig { - /// Resolve schema selection with default fallback - /// Returns (schema_name, schema_ron) with default applied if neither is provided - pub fn resolve_schema(&self) -> (Option<&str>, Option<&str>) { - match (self.schema.as_deref(), self.schema_ron.as_deref()) { - (Some(name), None) => (Some(name), None), - (None, Some(ron)) => (None, Some(ron)), - (Some(_), Some(_)) => (self.schema.as_deref(), self.schema_ron.as_deref()), // Both provided - let validation handle conflict - (None, None) => (Some("zerv-standard"), None), // Default fallback - } - } -} diff --git a/src/cli/version/args/mod.rs b/src/cli/version/args/mod.rs index c76aac0..a4710fe 100644 --- a/src/cli/version/args/mod.rs +++ b/src/cli/version/args/mod.rs @@ -108,10 +108,4 @@ impl VersionArgs { pub fn dirty_override(&self) -> Option { self.overrides.dirty_override() } - - /// Resolve schema selection with default fallback - /// Returns (schema_name, schema_ron) with default applied if neither is provided - pub fn resolve_schema(&self) -> (Option<&str>, Option<&str>) { - self.main.resolve_schema() - } } diff --git a/src/cli/version/args/tests/combination_tests.rs b/src/cli/version/args/tests/combination_tests.rs index 55e5862..631081e 100644 --- a/src/cli/version/args/tests/combination_tests.rs +++ b/src/cli/version/args/tests/combination_tests.rs @@ -340,11 +340,3 @@ fn test_validate_pre_release_flags_no_conflict() { assert_eq!(args.bumps.bump_pre_release_label, Some("beta".to_string())); assert!(args.validate().is_ok()); } - -#[test] -fn test_resolve_schema() { - let args = VersionArgs::default(); - let (schema_name, schema_ron) = args.resolve_schema(); - assert_eq!(schema_name, Some("zerv-standard")); - assert_eq!(schema_ron, None); -} diff --git a/src/cli/version/args/tests/main_tests.rs b/src/cli/version/args/tests/main_tests.rs index 2794b03..2353a6f 100644 --- a/src/cli/version/args/tests/main_tests.rs +++ b/src/cli/version/args/tests/main_tests.rs @@ -45,52 +45,3 @@ fn test_main_config_with_overrides() { assert_eq!(config.output_prefix, Some("version:".to_string())); assert_eq!(config.directory, Some("/path/to/repo".to_string())); } - -#[test] -fn test_resolve_schema_default() { - let config = MainConfig::default(); - let (schema_name, schema_ron) = config.resolve_schema(); - assert_eq!(schema_name, Some("zerv-standard")); - assert_eq!(schema_ron, None); -} - -#[test] -fn test_resolve_schema_preset() { - let config = MainConfig { - schema: Some("calver".to_string()), - ..Default::default() - }; - let (schema_name, schema_ron) = config.resolve_schema(); - assert_eq!(schema_name, Some("calver")); - assert_eq!(schema_ron, None); -} - -#[test] -fn test_resolve_schema_ron() { - let config = MainConfig { - schema_ron: Some("(precedence_order: [Major, Minor, Patch])".to_string()), - ..Default::default() - }; - let (schema_name, schema_ron) = config.resolve_schema(); - assert_eq!(schema_name, None); - assert_eq!( - schema_ron, - Some("(precedence_order: [Major, Minor, Patch])") - ); -} - -#[test] -fn test_resolve_schema_both_provided() { - let config = MainConfig { - schema: Some("calver".to_string()), - schema_ron: Some("(precedence_order: [Major, Minor, Patch])".to_string()), - ..Default::default() - }; - let (schema_name, schema_ron) = config.resolve_schema(); - // Both provided - let validation handle conflict - assert_eq!(schema_name, Some("calver")); - assert_eq!( - schema_ron, - Some("(precedence_order: [Major, Minor, Patch])") - ); -} diff --git a/src/cli/version/zerv_draft.rs b/src/cli/version/zerv_draft.rs index e26833f..8115649 100644 --- a/src/cli/version/zerv_draft.rs +++ b/src/cli/version/zerv_draft.rs @@ -31,8 +31,8 @@ impl ZervDraft { self.vars.apply_context_overrides(args)?; // Then create the Zerv object - let (schema_name, schema_ron) = args.resolve_schema(); - let mut zerv = self.create_zerv_version(schema_name, schema_ron)?; + // let (schema_name, schema_ron) = args.resolve_schema(); + let mut zerv = self.create_zerv_version(args)?; // Resolve templates using the current Zerv state let resolved_args = ResolvedArgs::resolve(args, &zerv)?; @@ -44,12 +44,9 @@ impl ZervDraft { Ok(zerv) } - pub fn create_zerv_version( - self, - schema_name: Option<&str>, - schema_ron: Option<&str>, - ) -> Result { - // Move the logic from crate::schema::create_zerv_version here + pub fn create_zerv_version(self, args: &VersionArgs) -> Result { + let schema_name = args.main.schema.as_deref(); + let schema_ron = args.main.schema_ron.as_deref(); let schema = match (schema_name, schema_ron) { // Custom RON schema (None, Some(ron_str)) => parse_ron_schema(ron_str)?, @@ -74,9 +71,7 @@ impl ZervDraft { if let Some(existing_schema) = self.schema { existing_schema } else { - return Err(ZervError::MissingSchema( - "Either schema_name or schema_ron must be provided".to_string(), - )); + get_preset_schema("zerv-standard", &self.vars).unwrap() } } }; @@ -88,6 +83,16 @@ impl ZervDraft { #[cfg(test)] mod tests { use super::*; + use crate::cli::version::args::{ + MainConfig, + OverridesConfig, + VersionArgs, + }; + use crate::version::zerv::bump::precedence::PrecedenceOrder; + use crate::version::zerv::{ + Component, + Var, + }; #[test] fn test_zerv_draft_creation() { @@ -95,12 +100,6 @@ mod tests { let draft = ZervDraft::new(vars.clone(), None); assert_eq!(draft.vars, vars); assert!(draft.schema.is_none()); - - use crate::version::zerv::bump::precedence::PrecedenceOrder; - use crate::version::zerv::{ - Component, - Var, - }; let schema = ZervSchema::new_with_precedence( vec![Component::Var(Var::Major)], vec![], @@ -114,11 +113,6 @@ mod tests { #[test] fn test_to_zerv_with_overrides() { - use crate::cli::version::args::{ - OverridesConfig, - VersionArgs, - }; - let vars = ZervVars { major: Some(1), minor: Some(2), @@ -152,17 +146,22 @@ mod tests { ..Default::default() }; - // Test that create_zerv_version requires explicit schema (no default) + // Test with no schema (should use default) let draft = ZervDraft::new(vars.clone(), None); - let result = draft.create_zerv_version(None, None); - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ZervError::MissingSchema(_))); + let args = VersionArgs::default(); + let zerv = draft.create_zerv_version(&args).unwrap(); + assert_eq!(zerv.schema, ZervSchema::zerv_standard_tier_1()); - // Test with explicit schema (should work) + // Test with explicit schema let draft = ZervDraft::new(vars, None); - let zerv = draft - .create_zerv_version(Some("zerv-standard"), None) - .unwrap(); + let args = VersionArgs { + main: MainConfig { + schema: Some("zerv-standard".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let zerv = draft.create_zerv_version(&args).unwrap(); assert_eq!(zerv.schema, ZervSchema::zerv_standard_tier_1()); } @@ -182,7 +181,14 @@ mod tests { "#; let draft = ZervDraft::new(vars, None); - let zerv = draft.create_zerv_version(None, Some(ron_schema)).unwrap(); + let args = VersionArgs { + main: MainConfig { + schema_ron: Some(ron_schema.to_string()), + ..Default::default() + }, + ..Default::default() + }; + let zerv = draft.create_zerv_version(&args).unwrap(); assert_eq!(zerv.schema.core().len(), 2); assert_eq!(zerv.schema.build().len(), 1); } @@ -192,7 +198,15 @@ mod tests { let vars = ZervVars::default(); let ron_schema = "ZervSchema(core: [], extra_core: [], build: [], precedence_order: [])"; let draft = ZervDraft::new(vars, None); - let result = draft.create_zerv_version(Some("zerv-standard"), Some(ron_schema)); + let args = VersionArgs { + main: MainConfig { + schema: Some("zerv-standard".to_string()), + schema_ron: Some(ron_schema.to_string()), + ..Default::default() + }, + ..Default::default() + }; + let result = draft.create_zerv_version(&args); assert!(matches!(result, Err(ZervError::ConflictingSchemas(_)))); } @@ -200,7 +214,14 @@ mod tests { fn test_unknown_schema_error() { let vars = ZervVars::default(); let draft = ZervDraft::new(vars, None); - let result = draft.create_zerv_version(Some("unknown"), None); + let args = VersionArgs { + main: MainConfig { + schema: Some("unknown".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let result = draft.create_zerv_version(&args); assert!(matches!(result, Err(ZervError::UnknownSchema(_)))); } @@ -209,18 +230,19 @@ mod tests { let vars = ZervVars::default(); let invalid_ron = "invalid ron syntax"; let draft = ZervDraft::new(vars, None); - let result = draft.create_zerv_version(None, Some(invalid_ron)); + let args = VersionArgs { + main: MainConfig { + schema_ron: Some(invalid_ron.to_string()), + ..Default::default() + }, + ..Default::default() + }; + let result = draft.create_zerv_version(&args); assert!(matches!(result, Err(ZervError::StdinError(_)))); } #[test] fn test_use_existing_schema_from_stdin() { - use crate::version::zerv::bump::precedence::PrecedenceOrder; - use crate::version::zerv::{ - Component, - Var, - }; - let vars = ZervVars::default(); let existing_schema = ZervSchema::new_with_precedence( vec![Component::Var(Var::Major)], @@ -232,7 +254,8 @@ mod tests { // Test using existing schema when no new schema is provided let draft = ZervDraft::new(vars, Some(existing_schema)); - let zerv = draft.create_zerv_version(None, None).unwrap(); + let args = VersionArgs::default(); + let zerv = draft.create_zerv_version(&args).unwrap(); assert_eq!(zerv.schema.core().len(), 1); assert_eq!(zerv.schema.extra_core().len(), 0); assert_eq!(zerv.schema.build().len(), 0); @@ -240,11 +263,6 @@ mod tests { #[test] fn test_zerv_schema_structure() { - use crate::version::zerv::{ - Component, - Var, - }; - // Create a simple ZervVars for tier 1 (tagged, clean) let vars = ZervVars { major: Some(1), @@ -256,9 +274,14 @@ mod tests { }; let draft = ZervDraft::new(vars, None); - let zerv = draft - .create_zerv_version(Some("zerv-standard"), None) - .unwrap(); + let args = VersionArgs { + main: MainConfig { + schema: Some("zerv-standard".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let zerv = draft.create_zerv_version(&args).unwrap(); // Test the actual schema structure println!("Core components: {:?}", zerv.schema.core()); @@ -274,11 +297,6 @@ mod tests { #[test] fn test_zerv_ron_roundtrip_schema() { - use crate::version::zerv::{ - Component, - Var, - }; - let vars = ZervVars { major: Some(1), minor: Some(2), @@ -289,9 +307,14 @@ mod tests { }; let draft = ZervDraft::new(vars, None); - let original = draft - .create_zerv_version(Some("zerv-standard"), None) - .unwrap(); + let args = VersionArgs { + main: MainConfig { + schema: Some("zerv-standard".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let original = draft.create_zerv_version(&args).unwrap(); let ron_string = original.to_string(); let parsed: Zerv = ron_string.parse().unwrap(); diff --git a/src/test_utils/zerv/zerv.rs b/src/test_utils/zerv/zerv.rs index e11a7d5..8edb817 100644 --- a/src/test_utils/zerv/zerv.rs +++ b/src/test_utils/zerv/zerv.rs @@ -73,6 +73,12 @@ impl ZervFixture { self } + /// Set dirty flag (chainable) + pub fn with_dirty(mut self, dirty: bool) -> Self { + self.zerv.vars.dirty = Some(dirty); + self + } + /// Add build component (chainable) pub fn with_build(mut self, component: Component) -> Self { let mut build = self.zerv.schema.build().clone(); diff --git a/tests/integration_tests/version/main/formats.rs b/tests/integration_tests/version/main/formats.rs new file mode 100644 index 0000000..8815741 --- /dev/null +++ b/tests/integration_tests/version/main/formats.rs @@ -0,0 +1,276 @@ +use rstest::rstest; +use zerv::test_utils::ZervFixture; +use zerv::version::PreReleaseLabel; + +use crate::util::TestCommand; + +// ============================================================================ +// Input Format Tests +// ============================================================================ + +// ============================================================================ +// Output Format Tests - Basic Conversions +// ============================================================================ + +#[rstest] +#[case::semver_basic((1, 2, 3), "semver", "1.2.3")] +#[case::pep440_basic((1, 2, 3), "pep440", "1.2.3")] +fn test_output_format_basic( + #[case] version: (u64, u64, u64), + #[case] format: &str, + #[case] expected: &str, +) { + let zerv_ron = ZervFixture::new() + .with_version(version.0, version.1, version.2) + .build() + .to_string(); + + let output = TestCommand::new() + .args_from_str(format!("version --source stdin --output-format {format}")) + .stdin(zerv_ron) + .assert_success(); + + assert_eq!(output.stdout().trim(), expected); +} + +#[test] +fn test_output_format_zerv_roundtrip() { + let original_zerv = ZervFixture::new() + .with_version(1, 2, 3) + .with_pre_release(PreReleaseLabel::Alpha, Some(1)) + .build(); + + let zerv_ron = original_zerv.to_string(); + + let output = TestCommand::new() + .args_from_str("version --source stdin --output-format zerv") + .stdin(zerv_ron) + .assert_success(); + + let parsed_zerv: zerv::version::Zerv = + ron::from_str(output.stdout().trim()).expect("Failed to parse output as Zerv RON"); + + assert_eq!( + parsed_zerv, original_zerv, + "Zerv output format should preserve structure" + ); +} + +// ============================================================================ +// Output Format Tests - Pre-release Versions +// ============================================================================ + +#[rstest] +#[case::alpha_semver(PreReleaseLabel::Alpha, Some(1), "semver", "1.0.0-alpha.1")] +#[case::alpha_pep440(PreReleaseLabel::Alpha, Some(1), "pep440", "1.0.0a1")] +#[case::beta_semver(PreReleaseLabel::Beta, Some(2), "semver", "1.0.0-beta.2")] +#[case::beta_pep440(PreReleaseLabel::Beta, Some(2), "pep440", "1.0.0b2")] +#[case::rc_semver(PreReleaseLabel::Rc, Some(3), "semver", "1.0.0-rc.3")] +#[case::rc_pep440(PreReleaseLabel::Rc, Some(3), "pep440", "1.0.0rc3")] +fn test_output_format_prerelease_conversion( + #[case] label: PreReleaseLabel, + #[case] number: Option, + #[case] format: &str, + #[case] expected: &str, +) { + let zerv_ron = ZervFixture::new() + .with_version(1, 0, 0) + .with_pre_release(label, number) + .build() + .to_string(); + + let output = TestCommand::new() + .args_from_str(format!("version --source stdin --output-format {format}")) + .stdin(zerv_ron) + .assert_success(); + + assert_eq!(output.stdout().trim(), expected); +} + +// ============================================================================ +// Output Format Tests - Extended Version Features (Epoch, Post, Dev) +// ============================================================================ + +#[rstest] +#[case::epoch_only_pep440(Some(2), None, None, "pep440", "2!1.0.0")] +#[case::epoch_only_semver(Some(2), None, None, "semver", "1.0.0-epoch.2")] +#[case::post_only_pep440(None, Some(5), None, "pep440", "1.0.0.post5")] +#[case::post_only_semver(None, Some(5), None, "semver", "1.0.0-post.5")] +#[case::dev_only_pep440(None, None, Some(3), "pep440", "1.0.0.dev3")] +#[case::dev_only_semver(None, None, Some(3), "semver", "1.0.0-dev.3")] +#[case::epoch_post_pep440(Some(1), Some(2), None, "pep440", "1!1.0.0.post2")] +#[case::epoch_post_semver(Some(1), Some(2), None, "semver", "1.0.0-epoch.1.post.2")] +#[case::epoch_dev_pep440(Some(1), None, Some(4), "pep440", "1!1.0.0.dev4")] +#[case::epoch_dev_semver(Some(1), None, Some(4), "semver", "1.0.0-epoch.1.dev.4")] +#[case::post_dev_pep440(None, Some(2), Some(5), "pep440", "1.0.0.post2.dev5")] +#[case::post_dev_semver(None, Some(2), Some(5), "semver", "1.0.0-post.2.dev.5")] +#[case::epoch_post_dev_pep440(Some(1), Some(2), Some(3), "pep440", "1!1.0.0.post2.dev3")] +#[case::epoch_post_dev_semver(Some(1), Some(2), Some(3), "semver", "1.0.0-epoch.1.post.2.dev.3")] +fn test_output_format_extended_features( + #[case] epoch: Option, + #[case] post: Option, + #[case] dev: Option, + #[case] format: &str, + #[case] expected: &str, +) { + let mut fixture = ZervFixture::new() + .with_version(1, 0, 0) + .with_standard_tier_3(); + + if let Some(e) = epoch { + fixture = fixture.with_epoch(e); + } + + if let Some(p) = post { + fixture = fixture.with_post(p); + } + + if let Some(d) = dev { + fixture = fixture.with_dev(d) + } + + let zerv_ron = fixture.build().to_string(); + + let output = TestCommand::new() + .args_from_str(format!("version --source stdin --output-format {format}")) + .stdin(zerv_ron) + .assert_success(); + + assert_eq!(output.stdout().trim(), expected); +} + +// ============================================================================ +// Format Combination Tests +// ============================================================================ + +/// Test that different output formats work correctly with stdin source +#[rstest] +#[case::semver_to_pep440("1.2.3-alpha.1", "pep440", "1.2.3a1")] +#[case::semver_to_semver("1.2.3-alpha.1", "semver", "1.2.3-alpha.1")] +#[case::semver_with_build_to_pep440("1.2.3-alpha.1+some.build", "pep440", "1.2.3a1+some.build")] +#[case::semver_with_build_to_semver( + "1.2.3-alpha.1+some.build", + "semver", + "1.2.3-alpha.1+some.build" +)] +#[case::pep440_to_semver("1.2.3a1", "semver", "1.2.3-alpha.1")] +#[case::pep440_to_pep440("1.2.3a1", "pep440", "1.2.3a1")] +fn test_output_format_with_different_inputs( + #[case] input_version: &str, + #[case] output_format: &str, + #[case] expected: &str, +) { + let zerv_ron = if input_version.contains('-') || input_version.contains('+') { + // SemVer format + ZervFixture::from_semver_str(input_version) + .build() + .to_string() + } else if input_version.contains('a') + || input_version.contains('b') + || input_version.contains("rc") + || input_version.contains('!') + { + // PEP440 format + ZervFixture::from_pep440_str(input_version) + .build() + .to_string() + } else { + // Basic version + let parts: Vec<&str> = input_version.split('.').collect(); + ZervFixture::new() + .with_version( + parts[0].parse().unwrap(), + parts[1].parse().unwrap(), + parts[2].parse().unwrap(), + ) + .build() + .to_string() + }; + + let output = TestCommand::new() + .args_from_str(format!( + "version --source stdin --output-format {output_format}" + )) + .stdin(zerv_ron) + .assert_success(); + + assert_eq!(output.stdout().trim(), expected); +} + +// ============================================================================ +// Format Validation and Error Handling +// ============================================================================ + +#[test] +fn test_invalid_output_format_rejected_by_clap() { + // Invalid output format should be rejected by clap's value_parser + let output = TestCommand::new() + .args_from_str("version --output-format invalid") + .assert_failure(); + + let stderr = output.stderr(); + assert!( + stderr.contains("invalid value 'invalid'") || stderr.contains("--output-format"), + "Should show clap validation error for invalid output format, got: {stderr}" + ); +} + +// ============================================================================ +// Format Consistency Tests +// ============================================================================ + +/// Test that format conversions are symmetric where possible +#[rstest] +#[case::basic_version("1.2.3")] +#[case::prerelease_alpha("1.0.0-alpha.1")] +#[case::prerelease_beta("2.0.0-beta.2")] +#[case::prerelease_rc("3.0.0-rc.1")] +fn test_format_roundtrip_semver_pep440(#[case] original_version: &str) { + // SemVer -> PEP440 -> SemVer roundtrip using stdin + let semver_zerv = ZervFixture::from_semver_str(original_version) + .build() + .to_string(); + + let pep440_output = TestCommand::new() + .args_from_str("version --source stdin --output-format pep440") + .stdin(semver_zerv) + .assert_success(); + + let pep440_zerv = ZervFixture::from_pep440_str(pep440_output.stdout().trim()) + .build() + .to_string(); + + let semver_output = TestCommand::new() + .args_from_str("version --source stdin --output-format semver") + .stdin(pep440_zerv) + .assert_success(); + + assert_eq!( + semver_output.stdout().trim(), + original_version, + "Roundtrip conversion should preserve version" + ); +} + +#[test] +fn test_output_format_zerv_with_complex_structure() { + // Test that complex Zerv structures are preserved through zerv output format + let original_zerv = ZervFixture::new() + .with_version(1, 2, 3) + .with_epoch(2) + .with_pre_release(PreReleaseLabel::Alpha, Some(1)) + .with_post(1) + .build(); + + let zerv_ron = original_zerv.to_string(); + + let output = TestCommand::new() + .args_from_str("version --source stdin --output-format zerv") + .stdin(zerv_ron) + .assert_success(); + + let parsed_zerv: zerv::version::Zerv = + ron::from_str(output.stdout().trim()).expect("Failed to parse complex Zerv RON"); + + assert_eq!(parsed_zerv, original_zerv); +} diff --git a/tests/integration_tests/version/main/mod.rs b/tests/integration_tests/version/main/mod.rs index ed814b8..2808d5b 100644 --- a/tests/integration_tests/version/main/mod.rs +++ b/tests/integration_tests/version/main/mod.rs @@ -1 +1,2 @@ +pub mod formats; pub mod sources; diff --git a/tests/integration_tests/version/main/sources/stdin.rs b/tests/integration_tests/version/main/sources/stdin.rs index c161150..53712b4 100644 --- a/tests/integration_tests/version/main/sources/stdin.rs +++ b/tests/integration_tests/version/main/sources/stdin.rs @@ -79,9 +79,14 @@ fn test_stdin_pep440_features( assert_eq!(output.stdout().trim(), expected); } -#[test] -fn test_stdin_zerv_roundtrip() { - let original_zerv = ZervFixture::new().with_version(3, 1, 4).build(); +#[rstest] +#[case::standard_tier_1(ZervFixture::new().with_standard_tier_1().with_version(3, 1, 4))] +#[case::standard_tier_2(ZervFixture::new().with_standard_tier_2().with_version(2, 0, 0))] +#[case::standard_tier_3(ZervFixture::new().with_standard_tier_3().with_version(1, 5, 2))] +#[case::calver_tier_1(ZervFixture::new().with_calver_tier_1().with_version(2024, 12, 1))] +#[case::calver_tier_2(ZervFixture::new().with_calver_tier_2().with_version(2024, 1, 0))] +fn test_stdin_zerv_roundtrip(#[case] fixture: ZervFixture) { + let original_zerv = fixture.build(); let zerv_ron = original_zerv.to_string(); From 38107d95f2d2193ec6f92fbaaa1fe21970db72da Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Sun, 19 Oct 2025 14:25:18 +0700 Subject: [PATCH 11/13] feat: improve test_output_format_with_different_inputs --- src/cli/version/zerv_draft.rs | 43 +++++++++------ .../integration_tests/version/main/formats.rs | 53 +++++++------------ 2 files changed, 45 insertions(+), 51 deletions(-) diff --git a/src/cli/version/zerv_draft.rs b/src/cli/version/zerv_draft.rs index 8115649..d136028 100644 --- a/src/cli/version/zerv_draft.rs +++ b/src/cli/version/zerv_draft.rs @@ -44,38 +44,47 @@ impl ZervDraft { Ok(zerv) } - pub fn create_zerv_version(self, args: &VersionArgs) -> Result { - let schema_name = args.main.schema.as_deref(); - let schema_ron = args.main.schema_ron.as_deref(); - let schema = match (schema_name, schema_ron) { + fn resolve_schema( + schema_name: Option<&str>, + schema_ron: Option<&str>, + existing_schema: Option, + vars: &ZervVars, + ) -> Result { + match (schema_name, schema_ron) { // Custom RON schema - (None, Some(ron_str)) => parse_ron_schema(ron_str)?, + (None, Some(ron_str)) => parse_ron_schema(ron_str), // Built-in schema (Some(name), None) => { - if let Some(schema) = get_preset_schema(name, &self.vars) { - schema + if let Some(schema) = get_preset_schema(name, vars) { + Ok(schema) } else { - return Err(ZervError::UnknownSchema(name.to_string())); + Err(ZervError::UnknownSchema(name.to_string())) } } // Error cases - (Some(_), Some(_)) => { - return Err(ZervError::ConflictingSchemas( - "Cannot specify both schema_name and schema_ron".to_string(), - )); - } + (Some(_), Some(_)) => Err(ZervError::ConflictingSchemas( + "Cannot specify both schema_name and schema_ron".to_string(), + )), (None, None) => { // If no new schema requested, use existing schema from stdin source - if let Some(existing_schema) = self.schema { - existing_schema + if let Some(existing_schema) = existing_schema { + Ok(existing_schema) } else { - get_preset_schema("zerv-standard", &self.vars).unwrap() + Ok(get_preset_schema("zerv-standard", vars).unwrap()) } } - }; + } + } + pub fn create_zerv_version(self, args: &VersionArgs) -> Result { + let schema = Self::resolve_schema( + args.main.schema.as_deref(), + args.main.schema_ron.as_deref(), + self.schema, + &self.vars, + )?; Zerv::new(schema, self.vars) } } diff --git a/tests/integration_tests/version/main/formats.rs b/tests/integration_tests/version/main/formats.rs index 8815741..45b1992 100644 --- a/tests/integration_tests/version/main/formats.rs +++ b/tests/integration_tests/version/main/formats.rs @@ -145,47 +145,32 @@ fn test_output_format_extended_features( /// Test that different output formats work correctly with stdin source #[rstest] -#[case::semver_to_pep440("1.2.3-alpha.1", "pep440", "1.2.3a1")] -#[case::semver_to_semver("1.2.3-alpha.1", "semver", "1.2.3-alpha.1")] -#[case::semver_with_build_to_pep440("1.2.3-alpha.1+some.build", "pep440", "1.2.3a1+some.build")] -#[case::semver_with_build_to_semver( - "1.2.3-alpha.1+some.build", +#[case::semver_to_pep440( + ZervFixture::from_semver_str("1.2.3-alpha.1+some.build"), + "pep440", + "1.2.3a1+some.build" +)] +#[case::semver_to_semver( + ZervFixture::from_semver_str("1.2.3-alpha.1+some.build"), "semver", "1.2.3-alpha.1+some.build" )] -#[case::pep440_to_semver("1.2.3a1", "semver", "1.2.3-alpha.1")] -#[case::pep440_to_pep440("1.2.3a1", "pep440", "1.2.3a1")] +#[case::pep440_to_semver( + ZervFixture::from_pep440_str("1.2.3a1+some.build"), + "semver", + "1.2.3-alpha.1+some.build" +)] +#[case::pep440_to_pep440( + ZervFixture::from_pep440_str("1.2.3a1+some.build"), + "pep440", + "1.2.3a1+some.build" +)] fn test_output_format_with_different_inputs( - #[case] input_version: &str, + #[case] zerv_fixture: ZervFixture, #[case] output_format: &str, #[case] expected: &str, ) { - let zerv_ron = if input_version.contains('-') || input_version.contains('+') { - // SemVer format - ZervFixture::from_semver_str(input_version) - .build() - .to_string() - } else if input_version.contains('a') - || input_version.contains('b') - || input_version.contains("rc") - || input_version.contains('!') - { - // PEP440 format - ZervFixture::from_pep440_str(input_version) - .build() - .to_string() - } else { - // Basic version - let parts: Vec<&str> = input_version.split('.').collect(); - ZervFixture::new() - .with_version( - parts[0].parse().unwrap(), - parts[1].parse().unwrap(), - parts[2].parse().unwrap(), - ) - .build() - .to_string() - }; + let zerv_ron = zerv_fixture.build().to_string(); let output = TestCommand::new() .args_from_str(format!( From 4dcafd78a563289be73c968e9f974da8300f51cc Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Mon, 20 Oct 2025 20:23:16 +0700 Subject: [PATCH 12/13] fix: return error when use --source stdin without stdin --- Cargo.lock | 10 +- src/cli/utils/format_handler.rs | 532 ++++++------------ .../version/main/sources/stdin.rs | 20 + 3 files changed, 207 insertions(+), 355 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cae1b79..3fc0f7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,11 +84,11 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -984,9 +984,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.106" +version = "2.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "2a26dbd934e5451d21ef060c018dae56fc073894c5a7896f882928a76e6d081b" dependencies = [ "proc-macro2", "quote", diff --git a/src/cli/utils/format_handler.rs b/src/cli/utils/format_handler.rs index b073147..69c1434 100644 --- a/src/cli/utils/format_handler.rs +++ b/src/cli/utils/format_handler.rs @@ -1,4 +1,7 @@ -use std::io::Read; +use std::io::{ + IsTerminal, + Read, +}; use std::str::FromStr; use crate::error::ZervError; @@ -37,6 +40,12 @@ impl InputFormatHandler { /// Parse stdin input expecting Zerv RON format with comprehensive validation pub fn parse_stdin_to_zerv() -> Result { + if std::io::stdin().is_terminal() { + return Err(ZervError::StdinError( + "No input provided via stdin. Use echo or pipe to provide input.".to_string(), + )); + } + // Read all input from stdin let mut input = String::new(); std::io::stdin() @@ -86,100 +95,92 @@ impl InputFormatHandler { #[cfg(test)] mod tests { - use super::*; - - #[test] - fn test_parse_version_string_semver() { - let result = InputFormatHandler::parse_version_string("1.2.3", "semver"); - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), VersionObject::SemVer(_))); - } - - #[test] - fn test_parse_version_string_pep440() { - let result = InputFormatHandler::parse_version_string("1.2.3a1", "pep440"); - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), VersionObject::PEP440(_))); - } - - #[test] - fn test_parse_version_string_auto_semver() { - let result = InputFormatHandler::parse_version_string("1.2.3", "auto"); - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), VersionObject::SemVer(_))); - } - - #[test] - fn test_parse_version_string_auto_pep440() { - let result = InputFormatHandler::parse_version_string("1.2.3a1", "auto"); - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), VersionObject::PEP440(_))); - } - - #[test] - fn test_parse_version_string_invalid_semver() { - let result = InputFormatHandler::parse_version_string("invalid", "semver"); - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ZervError::InvalidFormat(_))); - } + use rstest::rstest; - #[test] - fn test_parse_version_string_invalid_pep440() { - let result = InputFormatHandler::parse_version_string("invalid", "pep440"); - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ZervError::InvalidFormat(_))); - } + use super::*; + use crate::test_utils::zerv::ZervFixture; + use crate::version::zerv::{ + Component, + PreReleaseLabel, + }; + + #[rstest] + #[case::semver_valid("1.2.3", "semver", true, Some("SemVer"))] + #[case::pep440_valid("1.2.3a1", "pep440", true, Some("PEP440"))] + #[case::auto_semver("1.2.3", "auto", true, Some("SemVer"))] + #[case::auto_pep440("1.2.3a1", "auto", true, Some("PEP440"))] + #[case::semver_invalid("invalid", "semver", false, None)] + #[case::pep440_invalid("invalid", "pep440", false, None)] + #[case::unknown_format("1.2.3", "unknown", false, None)] + #[case::auto_invalid("invalid", "auto", false, None)] + fn test_parse_version_string( + #[case] version: &str, + #[case] format: &str, + #[case] should_succeed: bool, + #[case] expected_type: Option<&str>, + ) { + let result = InputFormatHandler::parse_version_string(version, format); + + if should_succeed { + assert!(result.is_ok(), "Should parse '{version}' as {format}"); + let version_obj = result.unwrap(); + + match expected_type { + Some("SemVer") => assert!(matches!(version_obj, VersionObject::SemVer(_))), + Some("PEP440") => assert!(matches!(version_obj, VersionObject::PEP440(_))), + _ => {} + } + } else { + assert!( + result.is_err(), + "Should fail to parse '{version}' as {format}" + ); - #[test] - fn test_parse_version_string_unknown_format() { - let result = InputFormatHandler::parse_version_string("1.2.3", "unknown"); - assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ZervError::UnknownFormat(_))); + // Verify error type based on format + let error = result.unwrap_err(); + match format { + "semver" | "pep440" => assert!(matches!(error, ZervError::InvalidFormat(_))), + "unknown" => assert!(matches!(error, ZervError::UnknownFormat(_))), + "auto" => assert!(matches!(error, ZervError::InvalidVersion(_))), + _ => {} + } + } } - #[test] - fn test_parse_version_string_auto_invalid() { - let result = InputFormatHandler::parse_version_string("invalid", "auto"); + #[rstest] + #[case::semver_invalid("invalid", "semver", &["Invalid SemVer format", "invalid"])] + #[case::pep440_invalid("invalid", "pep440", &["Invalid PEP440 format", "invalid"])] + #[case::unknown_format("1.2.3", "unknown", &["Unknown input format", "unknown", "Supported formats"])] + fn test_error_messages_format_specific( + #[case] version: &str, + #[case] format: &str, + #[case] expected_substrings: &[&str], + ) { + let result = InputFormatHandler::parse_version_string(version, format); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), ZervError::InvalidVersion(_))); - } - #[test] - fn test_error_messages_format_specific() { - // Test SemVer error message - let semver_error = InputFormatHandler::parse_version_string("invalid", "semver"); - assert!(semver_error.is_err()); - let error_msg = semver_error.unwrap_err().to_string(); - assert!(error_msg.contains("Invalid SemVer format")); - assert!(error_msg.contains("invalid")); - - // Test PEP440 error message - let pep440_error = InputFormatHandler::parse_version_string("invalid", "pep440"); - assert!(pep440_error.is_err()); - let error_msg = pep440_error.unwrap_err().to_string(); - assert!(error_msg.contains("Invalid PEP440 format")); - assert!(error_msg.contains("invalid")); - - // Test unknown format error message - let unknown_error = InputFormatHandler::parse_version_string("1.2.3", "unknown"); - assert!(unknown_error.is_err()); - let error_msg = unknown_error.unwrap_err().to_string(); - assert!(error_msg.contains("Unknown input format")); - assert!(error_msg.contains("unknown")); - assert!(error_msg.contains("Supported formats")); + let error_msg = result.unwrap_err().to_string(); + for substring in expected_substrings { + assert!( + error_msg.contains(substring), + "Error message should contain '{substring}': {error_msg}" + ); + } } - #[test] - fn test_auto_detection_priority() { - // SemVer should be detected first for ambiguous cases - let result = InputFormatHandler::parse_version_string("1.2.3", "auto"); + #[rstest] + #[case::ambiguous_semver("1.2.3", "SemVer")] + #[case::pep440_specific("1.2.3a1", "PEP440")] + fn test_auto_detection_priority(#[case] version: &str, #[case] expected_type: &str) { + let result = InputFormatHandler::parse_version_string(version, "auto"); assert!(result.is_ok()); - assert!(matches!(result.unwrap(), VersionObject::SemVer(_))); - // PEP440-specific syntax should be detected as PEP440 - let result = InputFormatHandler::parse_version_string("1.2.3a1", "auto"); - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), VersionObject::PEP440(_))); + let version_obj = result.unwrap(); + match expected_type { + "SemVer" => assert!(matches!(version_obj, VersionObject::SemVer(_))), + "PEP440" => assert!(matches!(version_obj, VersionObject::PEP440(_))), + _ => panic!("Unknown expected type: {expected_type}"), + } } #[test] @@ -213,10 +214,11 @@ mod tests { assert!(unknown_msg.contains("--input-format zerv")); } + // Note: parse_stdin_to_zerv() is tested via integration tests where we can control stdin. + // The parsing logic itself is thoroughly tested via parse_and_validate_zerv_ron() tests below. + #[test] fn test_parse_and_validate_zerv_ron_with_valid_input() { - use crate::test_utils::zerv::ZervFixture; - // Create a valid Zerv object and convert to RON let zerv = ZervFixture::basic().zerv().clone(); let ron_string = zerv.to_string(); @@ -229,163 +231,42 @@ mod tests { assert_eq!(parsed_zerv, zerv, "Parsed Zerv should match original"); } - #[test] - fn test_parse_and_validate_zerv_ron_with_simple_version_string() { - let simple_versions = vec!["1.2.3", "v2.0.0", "1.0.0-alpha"]; - - for version in simple_versions { - let result = InputFormatHandler::parse_and_validate_zerv_ron(version); - assert!( - result.is_err(), - "Should reject simple version string '{version}'" - ); - - let error = result.unwrap_err(); - match error { - ZervError::StdinError(msg) => { - assert!(msg.contains("Invalid input format")); - assert!(msg.contains("Zerv RON format only")); - } - _ => panic!("Expected StdinError for simple version string '{version}'"), - } - } - } - - #[test] - fn test_parse_and_validate_zerv_ron_with_semver_pep440_strings() { - let version_strings = vec![ - "1.2.3", - "2.0.0-alpha.1", - "1.0.0+build.123", - "1.2.3a1", - "2.0.0b2", - "1.0.0rc1", - ]; - - for version in version_strings { - let result = InputFormatHandler::parse_and_validate_zerv_ron(version); - assert!(result.is_err(), "Should reject version string '{version}'"); - - let error = result.unwrap_err(); - match error { - ZervError::StdinError(msg) => { - assert!(msg.contains("Invalid input format")); - assert!(msg.contains("Zerv RON format only")); - } - _ => panic!("Expected StdinError for version string '{version}'"), - } - } - } - - #[test] - fn test_parse_and_validate_zerv_ron_with_json_input() { - let json_inputs = vec![ - r#"{"schema": {"core": []}, "vars": {}}"#, - r#"[1, 2, 3]"#, - r#"{"version": "1.2.3"}"#, - ]; - - for json_input in json_inputs { - let result = InputFormatHandler::parse_and_validate_zerv_ron(json_input); - assert!(result.is_err(), "Should reject JSON input"); - - let error = result.unwrap_err(); - match error { - ZervError::StdinError(msg) => { - assert!(msg.contains("Invalid input format")); - assert!(msg.contains("Zerv RON format only")); - } - _ => panic!("Expected StdinError for JSON input"), - } - } - } - - #[test] - fn test_parse_and_validate_zerv_ron_with_invalid_ron() { - let invalid_ron_inputs = vec![ - "(schema: (core: [", // Incomplete RON - "(invalid_field: 123)", // Missing required fields - "(schema: (), vars: ())", // Empty schema - "(schema: (core: [VarField(\"major\")]), vars: ())", // Missing major var - ]; - - for invalid_input in invalid_ron_inputs { - let result = InputFormatHandler::parse_and_validate_zerv_ron(invalid_input); - assert!( - result.is_err(), - "Should reject invalid RON: '{invalid_input}'" - ); - - let error = result.unwrap_err(); - match error { - ZervError::StdinError(msg) => { - // Should contain helpful error information - assert!( - msg.contains("Invalid Zerv RON") || msg.contains("RON format"), - "Error message should mention RON format issues: {msg}" - ); - } - _ => panic!("Expected StdinError for invalid RON: '{invalid_input}'"), - } - } - } - - #[test] - fn test_comprehensive_stdin_validation_error_messages() { - // Test that all error messages provide actionable guidance - - // Simple version string error - let result = InputFormatHandler::parse_and_validate_zerv_ron("1.2.3"); - assert!(result.is_err()); - let error_msg = result.unwrap_err().to_string(); - assert!(error_msg.contains("Invalid input format")); - assert!(error_msg.contains("Zerv RON format only")); - - // JSON format error - let result = InputFormatHandler::parse_and_validate_zerv_ron(r#"{"version": "1.2.3"}"#); - assert!(result.is_err()); - let error_msg = result.unwrap_err().to_string(); - assert!(error_msg.contains("Invalid input format")); - assert!(error_msg.contains("Zerv RON format only")); - - // Invalid RON structure error - let result = InputFormatHandler::parse_and_validate_zerv_ron("(invalid: syntax"); - assert!(result.is_err()); - let error_msg = result.unwrap_err().to_string(); - assert!(error_msg.contains("Invalid input format")); - assert!(error_msg.contains("Zerv RON format only")); - } + #[rstest] + #[case::simple_version("1.2.3")] + #[case::v_prefix("v2.0.0")] + #[case::semver_prerelease("1.0.0-alpha")] + #[case::semver_prerelease_number("2.0.0-alpha.1")] + #[case::semver_build("1.0.0+build.123")] + #[case::pep440_alpha("1.2.3a1")] + #[case::pep440_beta("2.0.0b2")] + #[case::pep440_rc("1.0.0rc1")] + #[case::json_object(r#"{"schema": {"core": []}, "vars": {}}"#)] + #[case::json_array(r#"[1, 2, 3]"#)] + #[case::json_version(r#"{"version": "1.2.3"}"#)] + #[case::incomplete_ron("(schema: (core: [")] + #[case::missing_fields("(invalid_field: 123)")] + #[case::empty_schema("(schema: (), vars: ())")] + #[case::missing_var(r#"(schema: (core: [VarField("major")]), vars: ())"#)] + #[case::invalid_syntax("(invalid: syntax")] + fn test_parse_and_validate_zerv_ron_rejects_invalid_input(#[case] input: &str) { + let result = InputFormatHandler::parse_and_validate_zerv_ron(input); + assert!(result.is_err(), "Should reject input: '{input}'"); + + let error = result.unwrap_err(); + assert!( + matches!(error, ZervError::StdinError(_)), + "Should return StdinError for invalid input: '{input}'" + ); - #[test] - fn test_enhanced_ron_error_reporting() { - // Test that RON parsing errors include helpful hints - let invalid_inputs = vec![ - ("(schema: (core: [", "RON syntax"), - ("(missing_field: 123)", "schema"), - ("invalid syntax", "RON format"), - ]; - - for (input, _expected_hint) in invalid_inputs { - let result = InputFormatHandler::parse_and_validate_zerv_ron(input); - assert!(result.is_err(), "Should fail for input: '{input}'"); - - let error_msg = result.unwrap_err().to_string(); - assert!( - error_msg.contains("Invalid input format") - && error_msg.contains("Zerv RON format only"), - "Error should contain simplified message for '{input}': {error_msg}" - ); - } + let error_msg = error.to_string(); + assert!( + error_msg.contains("Invalid input format") || error_msg.contains("Stdin error"), + "Error message should be helpful for '{input}': {error_msg}" + ); } #[test] fn test_stdin_validation_with_complex_zerv_structures() { - use crate::test_utils::zerv::ZervFixture; - use crate::version::zerv::{ - Component, - PreReleaseLabel, - }; - // Test with pre-release Zerv let pre_release_zerv = ZervFixture::new() .with_pre_release(PreReleaseLabel::Alpha, Some(1)) @@ -413,8 +294,6 @@ mod tests { // Integration tests for comprehensive format handling #[test] fn test_zerv_ron_parsing() { - use crate::test_utils::zerv::ZervFixture; - // Create a sample Zerv object let zerv = ZervFixture::basic().zerv().clone(); let ron_string = zerv.to_string(); @@ -432,122 +311,75 @@ mod tests { assert_eq!(parsed_zerv, zerv); } - #[test] - fn test_version_parsing_comprehensive() { - // Test SemVer parsing - let semver_cases = vec![ - "1.2.3", - "1.0.0-alpha", - "1.0.0-alpha.1", - "1.0.0+build", - "1.0.0-alpha+build", - ]; - - for version in semver_cases { - let result = InputFormatHandler::parse_version_string(version, "semver"); - assert!( - result.is_ok(), - "Should parse SemVer '{version}' successfully" - ); - assert!(matches!(result.unwrap(), VersionObject::SemVer(_))); - } - - // Test PEP440 parsing - let pep440_cases = vec![ - "1.2.3", - "1.2.3a1", - "1.2.3b2", - "1.2.3rc1", - "1.2.3.post1", - "1.2.3.dev1", - "2!1.2.3", - ]; - - for version in pep440_cases { - let result = InputFormatHandler::parse_version_string(version, "pep440"); - assert!( - result.is_ok(), - "Should parse PEP440 '{version}' successfully" - ); - assert!(matches!(result.unwrap(), VersionObject::PEP440(_))); + #[rstest] + #[case::semver_basic("1.2.3", "semver", "SemVer")] + #[case::semver_prerelease("1.0.0-alpha", "semver", "SemVer")] + #[case::semver_prerelease_num("1.0.0-alpha.1", "semver", "SemVer")] + #[case::semver_build("1.0.0+build", "semver", "SemVer")] + #[case::semver_both("1.0.0-alpha+build", "semver", "SemVer")] + #[case::pep440_basic("1.2.3", "pep440", "PEP440")] + #[case::pep440_alpha("1.2.3a1", "pep440", "PEP440")] + #[case::pep440_beta("1.2.3b2", "pep440", "PEP440")] + #[case::pep440_rc("1.2.3rc1", "pep440", "PEP440")] + #[case::pep440_post("1.2.3.post1", "pep440", "PEP440")] + #[case::pep440_dev("1.2.3.dev1", "pep440", "PEP440")] + #[case::pep440_epoch("2!1.2.3", "pep440", "PEP440")] + #[case::auto_semver("1.2.3", "auto", "SemVer")] + #[case::auto_pep440_alpha("1.2.3a1", "auto", "PEP440")] + #[case::auto_pep440_epoch("2!1.2.3", "auto", "PEP440")] + fn test_version_parsing_comprehensive( + #[case] version: &str, + #[case] format: &str, + #[case] expected_type: &str, + ) { + let result = InputFormatHandler::parse_version_string(version, format); + assert!(result.is_ok(), "Should parse '{version}' as {format}"); + + let version_obj = result.unwrap(); + match expected_type { + "SemVer" => assert!(matches!(version_obj, VersionObject::SemVer(_))), + "PEP440" => assert!(matches!(version_obj, VersionObject::PEP440(_))), + _ => panic!("Unknown expected type: {expected_type}"), } + } - // Test auto-detection - let auto_cases = vec![ - ("1.2.3", "SemVer"), // Should prefer SemVer - ("1.2.3a1", "PEP440"), // PEP440-specific syntax - ("2!1.2.3", "PEP440"), // Epoch is PEP440-specific - ]; + #[rstest] + #[case::semver_invalid("invalid-version", "semver", &["Invalid SemVer format", "invalid-version"])] + #[case::pep440_invalid("invalid-version", "pep440", &["Invalid PEP440 format", "invalid-version"])] + #[case::unknown_format("1.2.3", "unknown", &["Unknown input format", "unknown", "Supported formats"])] + #[case::auto_invalid("completely-invalid", "auto", &["not valid SemVer or PEP440 format", "completely-invalid"])] + fn test_error_message_quality( + #[case] version: &str, + #[case] format: &str, + #[case] expected_substrings: &[&str], + ) { + let result = InputFormatHandler::parse_version_string(version, format); + assert!(result.is_err()); - for (version, expected_type) in auto_cases { - let result = InputFormatHandler::parse_version_string(version, "auto"); + let error_msg = result.unwrap_err().to_string(); + for substring in expected_substrings { assert!( - result.is_ok(), - "Should auto-detect '{version}' successfully" + error_msg.contains(substring), + "Error message should contain '{substring}': {error_msg}" ); - - match (result.unwrap(), expected_type) { - (VersionObject::SemVer(_), "SemVer") => {} - (VersionObject::PEP440(_), "PEP440") => {} - (actual, expected) => panic!( - "Auto-detection failed for '{version}': expected {expected}, got {actual:?}" - ), - } } } - #[test] - fn test_error_message_quality() { - // Test format-specific error messages - let invalid_semver = InputFormatHandler::parse_version_string("invalid-version", "semver"); - assert!(invalid_semver.is_err()); - let error_msg = invalid_semver.unwrap_err().to_string(); - assert!(error_msg.contains("Invalid SemVer format")); - assert!(error_msg.contains("invalid-version")); - - let invalid_pep440 = InputFormatHandler::parse_version_string("invalid-version", "pep440"); - assert!(invalid_pep440.is_err()); - let error_msg = invalid_pep440.unwrap_err().to_string(); - assert!(error_msg.contains("Invalid PEP440 format")); - assert!(error_msg.contains("invalid-version")); - - // Test unknown format error - let unknown_format = InputFormatHandler::parse_version_string("1.2.3", "unknown"); - assert!(unknown_format.is_err()); - let error_msg = unknown_format.unwrap_err().to_string(); - assert!(error_msg.contains("Unknown input format")); - assert!(error_msg.contains("unknown")); - assert!(error_msg.contains("Supported formats")); - - // Test auto-detection failure - let auto_invalid = InputFormatHandler::parse_version_string("completely-invalid", "auto"); - assert!(auto_invalid.is_err()); - let error_msg = auto_invalid.unwrap_err().to_string(); - assert!(error_msg.contains("not valid SemVer or PEP440 format")); - assert!(error_msg.contains("completely-invalid")); - } - - #[test] - fn test_case_insensitive_format_handling() { - // Test that format names are case-insensitive - let test_cases = vec![ - ("semver", "1.2.3"), - ("SEMVER", "1.2.3"), - ("SemVer", "1.2.3"), - ("pep440", "1.2.3a1"), - ("PEP440", "1.2.3a1"), - ("Pep440", "1.2.3a1"), - ("auto", "1.2.3"), - ("AUTO", "1.2.3"), - ("Auto", "1.2.3"), - ]; - - for (format, version) in test_cases { - let result = InputFormatHandler::parse_version_string(version, format); - assert!( - result.is_ok(), - "Should handle case-insensitive format '{format}' for version '{version}'" - ); - } + #[rstest] + #[case::semver_lower("semver", "1.2.3")] + #[case::semver_upper("SEMVER", "1.2.3")] + #[case::semver_mixed("SemVer", "1.2.3")] + #[case::pep440_lower("pep440", "1.2.3a1")] + #[case::pep440_upper("PEP440", "1.2.3a1")] + #[case::pep440_mixed("Pep440", "1.2.3a1")] + #[case::auto_lower("auto", "1.2.3")] + #[case::auto_upper("AUTO", "1.2.3")] + #[case::auto_mixed("Auto", "1.2.3")] + fn test_case_insensitive_format_handling(#[case] format: &str, #[case] version: &str) { + let result = InputFormatHandler::parse_version_string(version, format); + assert!( + result.is_ok(), + "Should handle case-insensitive format '{format}' for version '{version}'" + ); } } diff --git a/tests/integration_tests/version/main/sources/stdin.rs b/tests/integration_tests/version/main/sources/stdin.rs index 53712b4..88caa73 100644 --- a/tests/integration_tests/version/main/sources/stdin.rs +++ b/tests/integration_tests/version/main/sources/stdin.rs @@ -103,3 +103,23 @@ fn test_stdin_zerv_roundtrip(#[case] fixture: ZervFixture) { "Stdin roundtrip should preserve Zerv structure" ); } + +#[test] +fn test_stdin_without_input_returns_error() { + // Test that running with --source stdin but without providing stdin input + // returns an error immediately instead of hanging + // This verifies the terminal detection is working correctly + + let output = TestCommand::new() + .args_from_str("version --source stdin --output-format semver") + // Note: No .stdin() call here - stdin is not provided + .assert_failure(); + + // Verify we get a helpful error message about stdin being required + let stderr = output.stderr(); + assert!( + stderr.contains("No input provided via stdin") || stderr.contains("stdin"), + "Error message should mention stdin requirement. Got: {}", + stderr + ); +} From b2c78c5ca9d0f40fd372db81a83887c23eaa3fde Mon Sep 17 00:00:00 2001 From: Wisaroot Lertthaweedech Date: Mon, 20 Oct 2025 20:56:39 +0700 Subject: [PATCH 13/13] fix: fix duplicated codes --- src/cli/utils/template/helpers.rs | 596 +++++++++++++++++++++++++++--- 1 file changed, 535 insertions(+), 61 deletions(-) diff --git a/src/cli/utils/template/helpers.rs b/src/cli/utils/template/helpers.rs index b3cb539..52c0e65 100644 --- a/src/cli/utils/template/helpers.rs +++ b/src/cli/utils/template/helpers.rs @@ -31,6 +31,55 @@ pub fn register_helpers(handlebars: &mut Handlebars) -> Result<(), ZervError> { Ok(()) } +// ============================================================================ +// Parameter Extraction Helpers +// ============================================================================ + +/// Extract string parameter from a helper +fn extract_string_param<'a>( + h: &'a Helper, + helper_name: &str, +) -> Result<&'a str, handlebars::RenderError> { + h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other(format!( + "{helper_name} helper requires a string parameter" + ))) + }) +} + +/// Extract u64 parameter from a helper +fn extract_u64_param(h: &Helper, helper_name: &str) -> Result { + h.param(0).and_then(|v| v.value().as_u64()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other(format!( + "{helper_name} helper requires a numeric parameter" + ))) + }) +} + +/// Extract two numeric parameters from a helper +fn extract_two_numbers( + h: &Helper, + helper_name: &str, +) -> Result<(i64, i64), handlebars::RenderError> { + let a = h.param(0).and_then(|v| v.value().as_i64()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other(format!( + "{helper_name} helper requires two numeric parameters" + ))) + })?; + + let b = h.param(1).and_then(|v| v.value().as_i64()).ok_or_else(|| { + handlebars::RenderError::from(RenderErrorReason::Other(format!( + "{helper_name} helper requires two numeric parameters" + ))) + })?; + + Ok((a, b)) +} + +// ============================================================================ +// Helper Implementations +// ============================================================================ + fn sanitize_helper( h: &Helper, _: &Handlebars, @@ -38,11 +87,7 @@ fn sanitize_helper( _: &mut RenderContext, out: &mut dyn Output, ) -> HelperResult { - let value = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| { - handlebars::RenderError::from(RenderErrorReason::Other( - "sanitize helper requires a string parameter".to_string(), - )) - })?; + let value = extract_string_param(h, "sanitize")?; // Check for preset format let format = h.hash_get("preset").and_then(|v| v.value().as_str()); @@ -103,11 +148,7 @@ fn hash_helper( _: &mut RenderContext, out: &mut dyn Output, ) -> HelperResult { - let input = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| { - handlebars::RenderError::from(RenderErrorReason::Other( - "hash helper requires a string parameter".to_string(), - )) - })?; + let input = extract_string_param(h, "hash")?; let length = h.param(1).and_then(|v| v.value().as_u64()).unwrap_or(7) as usize; @@ -172,11 +213,7 @@ fn hash_int_helper( _: &mut RenderContext, out: &mut dyn Output, ) -> HelperResult { - let input = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| { - handlebars::RenderError::from(RenderErrorReason::Other( - "hash_int helper requires a string parameter".to_string(), - )) - })?; + let input = extract_string_param(h, "hash_int")?; let length = h.param(1).and_then(|v| v.value().as_u64()).unwrap_or(7) as usize; let allow_leading_zero = h @@ -213,11 +250,7 @@ fn prefix_helper( _: &mut RenderContext, out: &mut dyn Output, ) -> HelperResult { - let string = h.param(0).and_then(|v| v.value().as_str()).ok_or_else(|| { - handlebars::RenderError::from(RenderErrorReason::Other( - "prefix helper requires a string parameter".to_string(), - )) - })?; + let string = extract_string_param(h, "prefix")?; let length = h .param(1) @@ -242,11 +275,7 @@ fn format_timestamp_helper( _: &mut RenderContext, out: &mut dyn Output, ) -> HelperResult { - let timestamp = h.param(0).and_then(|v| v.value().as_u64()).ok_or_else(|| { - handlebars::RenderError::from(RenderErrorReason::Other( - "format_timestamp helper requires a timestamp parameter".to_string(), - )) - })?; + let timestamp = extract_u64_param(h, "format_timestamp")?; let format = h .hash_get("format") @@ -278,18 +307,7 @@ fn add_helper( _: &mut RenderContext, out: &mut dyn Output, ) -> HelperResult { - let a = h.param(0).and_then(|v| v.value().as_i64()).ok_or_else(|| { - handlebars::RenderError::from(RenderErrorReason::Other( - "add helper requires two numeric parameters".to_string(), - )) - })?; - - let b = h.param(1).and_then(|v| v.value().as_i64()).ok_or_else(|| { - handlebars::RenderError::from(RenderErrorReason::Other( - "add helper requires two numeric parameters".to_string(), - )) - })?; - + let (a, b) = extract_two_numbers(h, "add")?; out.write(&(a + b).to_string())?; Ok(()) } @@ -302,18 +320,7 @@ fn subtract_helper( _: &mut RenderContext, out: &mut dyn Output, ) -> HelperResult { - let a = h.param(0).and_then(|v| v.value().as_i64()).ok_or_else(|| { - handlebars::RenderError::from(RenderErrorReason::Other( - "subtract helper requires two numeric parameters".to_string(), - )) - })?; - - let b = h.param(1).and_then(|v| v.value().as_i64()).ok_or_else(|| { - handlebars::RenderError::from(RenderErrorReason::Other( - "subtract helper requires two numeric parameters".to_string(), - )) - })?; - + let (a, b) = extract_two_numbers(h, "subtract")?; out.write(&(a - b).to_string())?; Ok(()) } @@ -326,18 +333,7 @@ fn multiply_helper( _: &mut RenderContext, out: &mut dyn Output, ) -> HelperResult { - let a = h.param(0).and_then(|v| v.value().as_i64()).ok_or_else(|| { - handlebars::RenderError::from(RenderErrorReason::Other( - "multiply helper requires two numeric parameters".to_string(), - )) - })?; - - let b = h.param(1).and_then(|v| v.value().as_i64()).ok_or_else(|| { - handlebars::RenderError::from(RenderErrorReason::Other( - "multiply helper requires two numeric parameters".to_string(), - )) - })?; - + let (a, b) = extract_two_numbers(h, "multiply")?; out.write(&(a * b).to_string())?; Ok(()) } @@ -528,4 +524,482 @@ mod tests { fn test_math_helpers(#[case] template: &str, #[case] expected: &str) { assert_eq!(render_template(template), expected); } + + #[test] + fn test_sanitize_missing_parameter() { + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + let result = hb.render_template("{{sanitize}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires a string parameter") + ); + } + + #[test] + fn test_hash_missing_parameter() { + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + let result = hb.render_template("{{hash}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires a string parameter") + ); + } + + #[test] + fn test_hash_int_missing_parameter() { + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + let result = hb.render_template("{{hash_int}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires a string parameter") + ); + } + + #[test] + fn test_prefix_missing_parameter() { + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + let result = hb.render_template("{{prefix}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires a string parameter") + ); + } + + #[test] + fn test_format_timestamp_missing_parameter() { + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + let result = hb.render_template("{{format_timestamp}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires a numeric parameter") + ); + } + + #[test] + fn test_format_timestamp_invalid_timestamp() { + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + // Test with an extremely large timestamp that would be invalid + let result = hb.render_template("{{format_timestamp 99999999999999999}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Invalid timestamp") + ); + } + + #[test] + fn test_add_missing_parameters() { + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + // Missing first parameter + let result = hb.render_template("{{add}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires two numeric parameters") + ); + + // Missing second parameter + let result = hb.render_template("{{add 5}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires two numeric parameters") + ); + } + + #[test] + fn test_subtract_missing_parameters() { + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + let result = hb.render_template("{{subtract}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires two numeric parameters") + ); + + let result = hb.render_template("{{subtract 10}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires two numeric parameters") + ); + } + + #[test] + fn test_multiply_missing_parameters() { + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + let result = hb.render_template("{{multiply}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires two numeric parameters") + ); + + let result = hb.render_template("{{multiply 7}}", &()); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("requires two numeric parameters") + ); + } + + #[test] + fn test_format_with_leading_zeros_edge_cases() { + // Test when hash_str is longer than length (20 digit number, take first 5) + let result = format_with_leading_zeros(12345678901234567890, 5); + assert_eq!(result.len(), 5); + assert_eq!(result, "12345"); // First 5 chars of the string + + // Test length at boundary (exactly 20) + let result = format_with_leading_zeros(123, 20); + assert_eq!(result, "00000000000000000123"); + + // Test length > 20 + let result = format_with_leading_zeros(123, 25); + assert_eq!(result, "0000000000000000000000123"); + + // Test length < 20 with modulo - it uses modulo to get last N digits + let result = format_with_leading_zeros(456789, 10); + assert_eq!(result.len(), 10); + assert_eq!(result, "0000456789"); // 456789 padded to 10 digits + + // Test modulo case: number has more digits than length + let result = format_with_leading_zeros(123456789, 5); + assert_eq!(result.len(), 5); + assert_eq!(result, "12345"); // First 5 digits (hash_str is 9 chars, > 5) + + // Test length = 1 where hash_str.len() > length (takes first char) + let result = format_with_leading_zeros(12345, 1); + assert_eq!(result, "1"); // First char of "12345" + + // Test length = 1 with actual modulo (need 1-digit number or use modulo path) + let result = format_with_leading_zeros(7, 1); + assert_eq!(result, "7"); // 7 padded to 1 digit + + // Test modulo with length < hash_str length + let result = format_with_leading_zeros(789, 5); + assert_eq!(result, "00789"); // 789 padded to 5 digits + + // Test another case where hash_str length > requested length + let result = format_with_leading_zeros(9876543210987654321, 3); + assert_eq!(result.len(), 3); + assert_eq!(result, "987"); // First 3 chars + } + + #[test] + fn test_format_without_leading_zeros_edge_cases() { + // Test length = 0 + let result = format_without_leading_zeros(12345, 0); + assert_eq!(result, "0"); + + // Test length = 20 with short hash + let result = format_without_leading_zeros(123, 20); + assert!(result.len() == 20); + assert!(!result.starts_with('0')); + + // Test length = 20 with long hash + let result = format_without_leading_zeros(12345678901234567890, 20); + assert_eq!(result.len(), 20); + + // Test length = 1 + let result = format_without_leading_zeros(0, 1); + assert!(result.len() == 1); + + // Test various lengths ensure no leading zeros + for length in 2..10 { + let result = format_without_leading_zeros(123456789, length); + assert_eq!(result.len(), length); + assert!(!result.starts_with('0')); + } + } + + #[test] + fn test_hash_int_zero_length() { + let result = render_template("{{hash_int 'test' 0 allow_leading_zero=false}}"); + assert_eq!(result, "0"); + } + + #[test] + fn test_hash_int_various_lengths_with_leading_zero() { + // Test all lengths from 1 to 20 + for length in 1..=20 { + let result = render_template(&format!( + "{{{{hash_int 'test_input' {length} allow_leading_zero=true}}}}" + )); + assert!( + result.len() <= length, + "Length {length} resulted in '{result}' with len {}", + result.len() + ); + } + } + + #[test] + fn test_prefix_default_length() { + // When no length is provided, should return full string + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + let result = hb.render_template("{{prefix 'testing'}}", &()).unwrap(); + assert_eq!(result, "testing"); + } + + #[test] + fn test_format_timestamp_default_format() { + // Test default format when no format parameter is provided + let result = render_template("{{format_timestamp 1703123456}}"); + assert_eq!(result, "2023-12-21"); + } + + #[test] + fn test_hash_length_shorter_than_output() { + // Test when hash is naturally shorter than requested length + let mut hb = Handlebars::new(); + register_helpers(&mut hb).unwrap(); + + // Create a hash and verify it handles short hashes correctly + let result = hb.render_template("{{hash 'x' 100}}", &()).unwrap(); + // The result should be the full hash (not padded) + assert!(result.len() <= 100); + } + + // Tests for parameter extraction functions + mod extract_param_tests { + use handlebars::{ + Context, + Handlebars, + Helper, + HelperResult, + Output, + RenderContext, + }; + + use super::super::*; + + /// Helper to create a Handlebars instance with a test helper that extracts a string param + fn create_string_param_helper() -> Handlebars<'static> { + let mut hb = Handlebars::new(); + hb.register_helper( + "test", + Box::new( + |h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output| + -> HelperResult { + let value = extract_string_param(h, "test")?; + out.write(value)?; + Ok(()) + }, + ), + ); + hb + } + + /// Helper to create a Handlebars instance with a test helper that extracts a u64 param + fn create_u64_param_helper() -> Handlebars<'static> { + let mut hb = Handlebars::new(); + hb.register_helper( + "test", + Box::new( + |h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output| + -> HelperResult { + let value = extract_u64_param(h, "test")?; + out.write(&value.to_string())?; + Ok(()) + }, + ), + ); + hb + } + + /// Helper to create a Handlebars instance with a test helper that extracts two numbers + fn create_two_numbers_helper() -> Handlebars<'static> { + let mut hb = Handlebars::new(); + hb.register_helper( + "test", + Box::new( + |h: &Helper, + _: &Handlebars, + _: &Context, + _: &mut RenderContext, + out: &mut dyn Output| + -> HelperResult { + let (a, b) = extract_two_numbers(h, "test")?; + out.write(&format!("{a},{b}"))?; + Ok(()) + }, + ), + ); + hb + } + + /// Helper to assert error message contains expected text + fn assert_error_contains(result: Result, expected: &str) { + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains(expected)); + } + + #[test] + fn test_extract_string_param_success() { + let hb = create_string_param_helper(); + let result = hb.render_template("{{test 'hello'}}", &()).unwrap(); + assert_eq!(result, "hello"); + } + + #[test] + fn test_extract_string_param_missing() { + let hb = create_string_param_helper(); + assert_error_contains( + hb.render_template("{{test}}", &()), + "requires a string parameter", + ); + } + + #[test] + fn test_extract_string_param_wrong_type() { + let hb = create_string_param_helper(); + assert_error_contains( + hb.render_template("{{test 123}}", &()), + "requires a string parameter", + ); + } + + #[test] + fn test_extract_u64_param_success() { + let hb = create_u64_param_helper(); + let result = hb.render_template("{{test 42}}", &()).unwrap(); + assert_eq!(result, "42"); + } + + #[test] + fn test_extract_u64_param_missing() { + let hb = create_u64_param_helper(); + assert_error_contains( + hb.render_template("{{test}}", &()), + "requires a numeric parameter", + ); + } + + #[test] + fn test_extract_u64_param_wrong_type() { + let hb = create_u64_param_helper(); + assert_error_contains( + hb.render_template("{{test 'not a number'}}", &()), + "requires a numeric parameter", + ); + } + + #[test] + fn test_extract_u64_param_negative() { + let hb = create_u64_param_helper(); + // as_u64() returns None for negative numbers + assert_error_contains( + hb.render_template("{{test -5}}", &()), + "requires a numeric parameter", + ); + } + + #[test] + fn test_extract_two_numbers_success() { + let hb = create_two_numbers_helper(); + let result = hb.render_template("{{test 10 20}}", &()).unwrap(); + assert_eq!(result, "10,20"); + } + + #[test] + fn test_extract_two_numbers_negative() { + let hb = create_two_numbers_helper(); + let result = hb.render_template("{{test -5 3}}", &()).unwrap(); + assert_eq!(result, "-5,3"); + } + + #[test] + fn test_extract_two_numbers_missing_first() { + let hb = create_two_numbers_helper(); + assert_error_contains( + hb.render_template("{{test}}", &()), + "requires two numeric parameters", + ); + } + + #[test] + fn test_extract_two_numbers_missing_second() { + let hb = create_two_numbers_helper(); + assert_error_contains( + hb.render_template("{{test 10}}", &()), + "requires two numeric parameters", + ); + } + + #[test] + fn test_extract_two_numbers_wrong_type_first() { + let hb = create_two_numbers_helper(); + assert_error_contains( + hb.render_template("{{test 'abc' 10}}", &()), + "requires two numeric parameters", + ); + } + + #[test] + fn test_extract_two_numbers_wrong_type_second() { + let hb = create_two_numbers_helper(); + assert_error_contains( + hb.render_template("{{test 10 'xyz'}}", &()), + "requires two numeric parameters", + ); + } + } }