diff --git a/src/generator/commit_message.rs b/src/generator/commit_message.rs new file mode 100644 index 0000000..2ab01ba --- /dev/null +++ b/src/generator/commit_message.rs @@ -0,0 +1,244 @@ +use std::fmt; + +#[derive(Debug, Clone)] +pub struct BreakingChange { + pub description: String, + pub meta: Option, +} + +impl fmt::Display for BreakingChange { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(ref meta) = self.meta { + write!(f, "{} ({})", self.description, meta) + } else { + write!(f, "{}", self.description) + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct CommitMessage { + pub r#type: String, + pub scope: Option, + pub description: String, + pub body: Option, + pub breaking_changes: Vec, + pub footer: Option, +} + +impl CommitMessage { + pub fn new( + r#type: String, + scope: Option, + description: String, + body: Option, + ) -> Self { + CommitMessage { + r#type, + scope, + description, + body, + breaking_changes: Vec::new(), + footer: None, + } + } + + pub fn is_valid(&self) -> bool { + !self.r#type.trim().is_empty() && !self.description.trim().is_empty() + } + + pub fn format_header(&self) -> String { + let scope_part = self.scope.as_ref().map(|s| format!("({})", s)).unwrap_or_default(); + format!("{}{}: {}", self.r#type, scope_part, self.description) + } +} + +impl fmt::Display for CommitMessage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.format_header())?; + + if let Some(ref body) = self.body { + if !body.trim().is_empty() { + writeln!(f)?; + writeln!(f, "{}", body.trim())?; + } + } + + for breaking in &self.breaking_changes { + writeln!(f)?; + write!(f, "BREAKING CHANGE: {}", breaking)?; + } + + if let Some(ref footer) = self.footer { + if !footer.trim().is_empty() { + writeln!(f)?; + writeln!(f, "{}", footer.trim())?; + } + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Default)] +pub struct ConventionalCommitBuilder { + commit: CommitMessage, +} + +impl ConventionalCommitBuilder { + pub fn new() -> Self { + ConventionalCommitBuilder { + commit: CommitMessage::default(), + } + } + + pub fn with_type(mut self, r#type: String) -> Self { + self.commit.r#type = r#type; + self + } + + pub fn with_scope(mut self, scope: Option) -> Self { + self.commit.scope = scope; + self + } + + pub fn with_description(mut self, description: String) -> Self { + self.commit.description = description; + self + } + + pub fn with_body(mut self, body: Option) -> Self { + self.commit.body = body; + self + } + + pub fn with_breaking_change(mut self, description: String, meta: Option) -> Self { + self.commit.breaking_changes.push(BreakingChange { + description, + meta, + }); + self + } + + pub fn with_footer(mut self, footer: String) -> Self { + self.commit.footer = Some(footer); + self + } + + pub fn build(self) -> CommitMessage { + self.commit + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_commit_message_format() { + let msg = CommitMessage::new( + "feat".to_string(), + Some("auth".to_string()), + "add login functionality".to_string(), + Some("Users can now log in using OAuth".to_string()), + ); + + let formatted = msg.to_string(); + assert!(formatted.contains("feat(auth): add login functionality")); + assert!(formatted.contains("Users can now log in using OAuth")); + } + + #[test] + fn test_commit_message_with_breaking_change() { + let mut msg = CommitMessage::new( + "feat".to_string(), + Some("api".to_string()), + "update API response format".to_string(), + None, + ); + + msg.breaking_changes.push(BreakingChange { + description: "API response format changed".to_string(), + meta: Some("BREAKING".to_string()), + }); + + let formatted = msg.to_string(); + assert!(formatted.contains("BREAKING CHANGE:")); + } + + #[test] + fn test_commit_message_validation() { + let valid = CommitMessage::new( + "feat".to_string(), + None, + "add feature".to_string(), + None, + ); + assert!(valid.is_valid()); + + let invalid_type = CommitMessage::new( + String::new(), + None, + "add feature".to_string(), + None, + ); + assert!(!invalid_type.is_valid()); + + let invalid_desc = CommitMessage::new( + "feat".to_string(), + None, + String::new(), + None, + ); + assert!(!invalid_desc.is_valid()); + } + + #[test] + fn test_conventional_commit_builder() { + let msg = ConventionalCommitBuilder::new() + .with_type("fix".to_string()) + .with_scope(Some("bug".to_string())) + .with_description("fix a bug".to_string()) + .with_body(Some("this fixes the bug".to_string())) + .build(); + + assert_eq!(msg.r#type, "fix"); + assert_eq!(msg.scope, Some("bug".to_string())); + assert_eq!(msg.description, "fix a bug"); + } + + #[test] + fn test_commit_message_header_format() { + let msg = CommitMessage::new( + "feat".to_string(), + Some("core".to_string()), + "implement new algorithm".to_string(), + None, + ); + + assert_eq!(msg.format_header(), "feat(core): implement new algorithm"); + } + + #[test] + fn test_breaking_change_display() { + let breaking = BreakingChange { + description: "API changed".to_string(), + meta: Some("v2.0".to_string()), + }; + + let display = breaking.to_string(); + assert!(display.contains("API changed")); + assert!(display.contains("v2.0")); + } + + #[test] + fn test_breaking_change_without_meta() { + let breaking = BreakingChange { + description: "Breaking change without meta".to_string(), + meta: None, + }; + + let display = breaking.to_string(); + assert_eq!(display, "Breaking change without meta"); + } +}