Skip to main content

aigitcommit/git/
message.rs

1/*!
2 * Copyright (c) 2025-2026 mingcheng <mingcheng@apache.org>
3 *
4 * This source code is licensed under the MIT License,
5 * which is located in the LICENSE file in the source tree's root directory.
6 *
7 * File: message.rs
8 * Author: mingcheng <mingcheng@apache.org>
9 * File Created: 2025-10-16 15:06:58
10 *
11 * Modified By: mingcheng <mingcheng@apache.org>
12 * Last Modified: 2026-05-07 11:30:55
13 */
14
15use crate::git::repository::Repository;
16use std::fmt::Write as _;
17use std::{error::Error, fmt::Display};
18use tracing::trace;
19
20/// Represents a structured Git commit message
21///
22/// A commit message consists of:
23/// - `title`: The first line (subject line), typically 50-72 characters
24/// - `content`: The body of the commit message with detailed description
25#[derive(Debug, serde::Serialize)]
26pub struct GitMessage {
27    pub title: String,
28    pub content: String,
29}
30
31/// Configuration used when constructing a [`GitMessage`].
32///
33/// Grouping the construction parameters into a dedicated config struct keeps
34/// [`GitMessage::new`] easy to extend with future options (e.g. trailers,
35/// scope, breaking-change markers) without breaking call sites.
36#[derive(Debug, Clone, Default)]
37pub struct GitMessageConfig {
38    /// The commit title/subject line (will be trimmed).
39    pub title: String,
40    /// The commit body/description (will be trimmed).
41    pub content: String,
42    /// Whether to append a `Signed-off-by` trailer.
43    pub signoff: bool,
44}
45
46impl GitMessageConfig {
47    /// Convenience constructor for the most common fields.
48    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        // Format as: title\n\ncontent
60        write!(f, "{}\n\n{}", self.title, self.content)
61    }
62}
63
64impl GitMessage {
65    /// Create a new Git commit message from a [`GitMessageConfig`].
66    ///
67    /// # Arguments
68    /// * `repository` - The Git repository (used to get author info for signoff)
69    /// * `config` - Construction parameters; see [`GitMessageConfig`]
70    ///
71    /// # Returns
72    /// * `Ok(GitMessage)` - A valid commit message
73    /// * `Err` - If title or content is empty after trimming
74    ///
75    pub fn new(repository: &Repository, config: GitMessageConfig) -> Result<Self, Box<dyn Error>> {
76        // Trim inputs first to check actual content
77        let title_trimmed = config.title.trim();
78        let content_trimmed = config.content.trim();
79
80        // Validate both title and content are non-empty
81        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        // Append signoff line if requested
91        if config.signoff {
92            trace!("adding Signed-off-by line to commit message");
93            let author = repository.get_author()?;
94            // Writing into the existing String avoids the intermediate alloc
95            // that `format!` + `push_str` would create.
96            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        // Signoff is separated from body by a blank line.
160        assert!(msg.content.contains("\n\nSigned-off-by:"));
161    }
162}