aigitcommit/git/
message.rs1use crate::git::repository::Repository;
16use std::fmt::Write as _;
17use std::{error::Error, fmt::Display};
18use tracing::trace;
19
20#[derive(Debug, serde::Serialize)]
26pub struct GitMessage {
27 pub title: String,
28 pub content: String,
29}
30
31#[derive(Debug, Clone, Default)]
37pub struct GitMessageConfig {
38 pub title: String,
40 pub content: String,
42 pub signoff: bool,
44}
45
46impl GitMessageConfig {
47 pub fn new(title: impl Into<String>, content: impl Into<String>, signoff: bool) -> Self {
49 Self {
50 title: title.into(),
51 content: content.into(),
52 signoff,
53 }
54 }
55}
56
57impl Display for GitMessage {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 write!(f, "{}\n\n{}", self.title, self.content)
61 }
62}
63
64impl GitMessage {
65 pub fn new(repository: &Repository, config: GitMessageConfig) -> Result<Self, Box<dyn Error>> {
76 let title_trimmed = config.title.trim();
78 let content_trimmed = config.content.trim();
79
80 if title_trimmed.is_empty() {
82 return Err("commit title cannot be empty".into());
83 }
84 if content_trimmed.is_empty() {
85 return Err("commit content cannot be empty".into());
86 }
87
88 let mut final_content = content_trimmed.to_string();
89
90 if config.signoff {
92 trace!("adding Signed-off-by line to commit message");
93 let author = repository.get_author()?;
94 write!(
97 final_content,
98 "\n\nSigned-off-by: {} <{}>",
99 author.name, author.email
100 )?;
101 }
102
103 trace!("created commit message with title: {}", title_trimmed);
104 trace!("content length: {} characters", final_content.len());
105
106 Ok(Self {
107 title: title_trimmed.to_string(),
108 content: final_content,
109 })
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use std::env;
117
118 fn setup() -> Option<Repository> {
119 let path = env::var("TEST_REPO_PATH").unwrap_or_else(|_| ".".to_string());
120 Repository::new(&path).ok()
121 }
122
123 #[test]
124 fn rejects_empty_title() {
125 let Some(repo) = setup() else { return };
126 let err = GitMessage::new(&repo, GitMessageConfig::new(" ", "body", false)).unwrap_err();
127 assert!(err.to_string().contains("title"));
128 }
129
130 #[test]
131 fn rejects_empty_content() {
132 let Some(repo) = setup() else { return };
133 let err = GitMessage::new(&repo, GitMessageConfig::new("title", " ", false)).unwrap_err();
134 assert!(err.to_string().contains("content"));
135 }
136
137 #[test]
138 fn trims_inputs_and_formats_display() {
139 let Some(repo) = setup() else { return };
140 let msg = GitMessage::new(
141 &repo,
142 GitMessageConfig::new(" feat: x ", " body line ", false),
143 )
144 .unwrap();
145 assert_eq!(msg.title, "feat: x");
146 assert_eq!(msg.content, "body line");
147 assert_eq!(format!("{msg}"), "feat: x\n\nbody line");
148 }
149
150 #[test]
151 fn appends_signoff_line_when_requested() {
152 let Some(repo) = setup() else { return };
153 let msg = GitMessage::new(&repo, GitMessageConfig::new("feat: x", "body", true)).unwrap();
154 assert!(
155 msg.content.contains("Signed-off-by:"),
156 "signoff line missing: {}",
157 msg.content
158 );
159 assert!(msg.content.contains("\n\nSigned-off-by:"));
161 }
162}